From ddd6e0cdbce3dc0bff3b6dc2a4c7d054d77756f2 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 12 Jul 2026 09:19:43 -0400 Subject: [PATCH 01/11] Stop tracking TODO.md; remove completed plan docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO.md is personal working notes — untrack it (kept locally) and gitignore it. Delete GPU_VIDEO_DECODE_PLAN.md and STREAMING_TO_DISK_PLAN.md (the latter already removed); both plans are complete. --- .gitignore | 5 +- TODO.md | 25 ---- lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md | 142 ---------------------- 3 files changed, 4 insertions(+), 168 deletions(-) delete mode 100644 TODO.md delete mode 100644 lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md diff --git a/.gitignore b/.gitignore index ada2bc0..47cb259 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ packaging/AppDir/ packaging/squashfs-root/ # Wrapper script (generated, not needed with static FFmpeg) -lightningbeam-ui/lightningbeam-editor/debian/ \ No newline at end of file +lightningbeam-ui/lightningbeam-editor/debian/ + +# Personal working notes (not version-controlled) +TODO.md \ No newline at end of file diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a718f1e..0000000 --- a/TODO.md +++ /dev/null @@ -1,25 +0,0 @@ -# Lightningbeam TODO - -## Known Issues (Rust) - -### Animation: Tweens are broken — LOW PRIORITY -- Shape/vector interpolation between keyframes, and the `tween_after` behavior on - keyframes, don't work correctly in the current app. Needs investigation + fix. - Not urgent — revisit later. - -## Backlog / Feature ideas - -### Animation curve enhancements -- [ ] Extrapolation modes, separate for start vs end: hold (default), extend, repeat, decay -- [ ] Position / scale / rotation animation curves for shapes -- [ ] Shape morphing / tweening between keyframes - -### Keyframing behavior -- [ ] User preference for keyframing when editing objects: - - Auto-keyframe (current default): create/update keyframe at current time - - Edit previous (Flash-style): update most recent keyframe before current time - - Ephemeral (Blender-style): changes don't persist without manual keyframe - - Optional modifier key (e.g. Shift) to toggle modes - -### Shape ordering -- [ ] Bring Forward / Send Backward / Bring to Front / Send to Back menu options diff --git a/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md b/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md deleted file mode 100644 index 17e2d19..0000000 --- a/lightningbeam-ui/GPU_VIDEO_DECODE_PLAN.md +++ /dev/null @@ -1,142 +0,0 @@ -# GPU-resident video decode + dynamic decode resolution - -## Context - -Profiling the zero-copy H.264 export (single Group[Video, Audio] clip, `LB_RENDER_PROFILE=1`) -broke the per-frame CPU "render" bucket down as: - -| Cost (ms/frame) | 1080p | 4K | What it is | -|-------------------------|-------|-------|-----------------------------------------------------| -| decode | 3.1 | 19.0 | software ffmpeg decode (`video.rs::get_frame`) | -| background re-render | 3.6 | 7.5 | static background pushed through Vello *every frame* | -| video upload + blit | 4.1 | 4.2 | per-frame transient texture alloc + `write_texture` | -| srgb | 0.4 | 0.4 | linear→sRGB pass | - -The video correctly takes the GPU Video-instance path (not Vello-baked) — `LB_LAYER_DEBUG=1` -shows `Video (1 instance)`. So the cost is **the video frame itself**: software decode, then an -8 MB `write_texture` upload of the decoded RGBA every frame. At 4K, software decode (19 ms) -dominates everything. - -### Two correctness problems found alongside the perf issue - -1. **Decode resolution is frozen to document size at import.** `load_video(clip, src, doc_w, doc_h)` - (`main.rs:4302`) sizes the decoder's swscale output to the document, capped to never upscale - (`video.rs:149`). Export *reuses that decoder*, so exporting **above** document resolution yields - video that was decoded to ≤document res and then GPU-**up**scaled — real source detail thrown away. -2. **It can't follow the consumer or a document resize.** Preview wants small/fast frames; export - wants full res; changing the document size should re-target the decode. None of that works with a - size frozen at import. - -## Goal - -Decouple **decode resolution** from import/document size: the renderer requests a frame *at a target -resolution*, and the decode path produces it. Hardware-decode H.264 (and later HEVC/AV1) into a GPU -surface and keep it GPU-resident through composite into the encoder — no CPU frame copy in either -direction. Software decode stays a **first-class** path (codecs/platforms without HW support), decoding -at the requested target res. This fixes the 4K decode wall, the 8 MB upload, *and* the resolution bugs. - -## Design principles - -- **Decode native, scale to the consumer's target.** - - *Hardware path:* decode into a native VAAPI surface → import as a wgpu texture (reuse the - `gpu-video-encoder` `dmabuf.rs` / `vk_device.rs` plumbing, read direction) → the GPU blit that - already composites the Video instance scales native→target for free. Handles any target res and - document resizes inherently; the cached frame is a native GPU texture. - - *Software path:* decode native → `swscale` to the requested target (the reusable scaler is keyed - on input format/size **and** output size — rebuilt when the target changes). Preview requests - preview res (cheap); export requests export res (full quality). -- **`VideoManager::get_frame` takes a target `(w, h)`** instead of relying on a frozen output size. - The frame cache is keyed to handle multiple live targets (preview + export) — either cache native - frames and scale on demand, or key by `(clip, ts, target)`; decide in Stage 2 by measuring cache - hit/scale tradeoff. -- **Software is not optional.** Hardware decode is an acceleration of the same `get_frame` contract, - selected per source when the codec/driver supports it; everything falls back to software cleanly. - -## Approach (staged; each stage compiles + is independently useful) - -### Stage 0 — independent quick wins (not blocked on decode) -- **Cache the static background** (`composite_document_to_hdr`): render once, reuse via a persistent - HDR texture (copy-in each frame) instead of a full Vello render + 2 passes/submits every frame. - Recovers ~3.6 ms (1080p) / ~7.5 ms (4K) per frame on *every* export. (In flight.) - -### Stage 1 — software: decode at the requested target res (testable; fixes the quality bug now) -- Change `VideoManager::get_frame(clip, ts)` → `get_frame(clip, ts, target_w, target_h)`; thread the - target from the renderer (preview = current doc/preview res, export = export res). Cap at native. -- Rework `VideoDecoder` so output size is per-request, not frozen at construction; cache the swscale - context per output size (already cached per stream — extend the key). Adjust the frame cache key. -- Result: software exports are full-quality at any export res, and document resizes re-target decode. - No hardware needed; this is the correctness fix for the codecs HW can't handle anyway. - -### Stage 2 — hardware decode primitive (DONE, commit 255e164) -`decoder::VaapiDecoder` in `gpu-video-encoder`: decode → VAAPI surface → DRM-PRIME DMA-BUF → -`dmabuf::import_raw` → wgpu textures. Round-trip test (encode gray → decode → readback Y≈128) passes. - -### The device-affinity problem (drives the whole rest of the design) -wgpu textures can't cross devices, and a decoded frame is a wgpu texture imported from a DMA-BUF — -which **requires a device with the DMA-BUF-import extensions** (`VK_EXT_image_drm_format_modifier` -+ external-memory), built via wgpu-hal `device_from_raw` (the safe `DeviceDescriptor` can't add -them). So a hardware-decoded frame is only usable by a compositor running on **such** a device. -- **Export** composites on the encoder's custom device → already fine. -- **Preview** composites on eframe's *normal* device → can't import DMA-BUFs → can't use HW frames. - -Since **preview must HW-decode 4K** (software 4K decode ≈19 ms/frame), the resolution is a **single -shared custom device** used by eframe + preview compositor + decoder + encoder. eframe 0.33 (local -`egui-fork`) accepts it via `WgpuSetup::Existing { instance, adapter, device, queue }` — confirmed. -The earlier "separate export device" becomes redundant once this lands. - -### Stage 3a — windowed shared `DrmDevice`, injected into eframe (highest-risk; blind) -Today `vk_device::create()` is **headless**. Make a windowed variant (or extend it) that is a -**superset** device: DMA-BUF import ext **+** `VK_KHR_swapchain` (device) and the WSI surface -instance extensions, **+** everything eframe/egui/vello need — `adapter.limits()` (already; Vello -needs `max_storage_buffers_per_shader_stage` ≥ 5), `max_texture_dimension_2d` 8192, and the optional -features main.rs requests (`SHADER_F16`, `TIMESTAMP_QUERY[_INSIDE_ENCODERS]`). Pick the adapter that -is the **VAAPI GPU** (the render node must match libva's, or DMA-BUF sharing fails on multi-GPU). -- main.rs: try to build the shared device; on success pass `WgpuSetup::Existing`, else fall back to - the current `WgpuSetupCreateNew` (software decode only). Gate on Linux + VAAPI + a config/env - override; **must be bulletproof** — this device now renders *every* frame of *every* session for - Linux/VAAPI users, video or not. Milestone: editor runs normally on it with no video involved. - -### Stage 3b — VideoManager hardware decode on the shared device (blind) -- `VideoManager` holds a `VaapiDecoder` per HW-decodable clip (built on the shared device), plus the - software `VideoDecoder` fallback. `get_frame` gains a GPU-returning variant: yields an imported NV12 - texture pair (native res) instead of `Arc>`. Probe HW support per source; non-VAAPI / - unsupported codecs / non-Linux → software path (Stage 1, target-res). -- Cache native GPU textures keyed by (clip, ts); revisit the byte budget (4K NV12 ≈ 12 MB each). - -### Stage 3c — compositor consumes the GPU frame (blind; user-verifies) -- The video-instance composite path takes an NV12 texture (or a small NV12→RGB GPU pass) and blits it - to the target with the existing bilinear blit — **no `write_texture` upload**. GPU scales native→ - target (preview res or export res). Both preview and the zero-copy export become - decode→composite(→encode) with no CPU frame. Software frames still upload as today. - -## Critical files -- `lightningbeam-core/src/video.rs` — `VideoDecoder` (per-request output size, scaler cache), - `VideoManager::get_frame` (target param, cache key). -- `lightningbeam-core/src/renderer.rs` — pass the render target res into the video-instance build. -- `lightningbeam-editor/src/export/video_exporter.rs` — background cache (Stage 0); consume a GPU - texture instead of uploading RGBA (Stage 3). -- `gpu-video-encoder/` (→ `gpu-video-codec`) — `dmabuf.rs`/`vk_device.rs` reused for the decode import. - -## Risks -- **Shared custom device is the editor's main device (BIGGEST risk)** — Stage 3a makes a hand-built - wgpu-hal Vulkan device render every frame for Linux/VAAPI users. It must satisfy eframe + egui + - vello + winit presentation across varied Intel/AMD/Mesa stacks, or the editor won't start. Mitigate - with a strict try-and-fall-back-to-normal-device path + an env/config kill switch. Test broadly. -- **Multi-GPU** — the shared render device must be the *same* GPU as libva's VAAPI device, or DMA-BUF - import fails. Adapter selection must match the render node to the VAAPI node (laptops with iGPU + - dGPU, PRIME). -- **Codec coverage** — only some codecs are HW-decodable per GPU/driver; software must stay correct - and well-tested. Probe support per source, don't assume. -- **Cache memory** — native-res GPU textures (esp. 4K NV12 ≈12 MB) are large; revisit the frame cache - budget, and the two live targets (preview res + export res) shouldn't thrash. -- **Colorspace/format** — VAAPI decode surfaces are NV12/tiled; import handles NV12, but 10-bit/HDR - (P010) needs format handling. Decoded NV12 also needs the right BT.601/709 + range on the NV12→RGB - read (mirror the encoder's color tags, [[gpu-video-decode]] color-range work). -- **Non-Linux / no-VAAPI** — must cleanly run on the normal eframe device with software decode. - -## Verification -- Stage 0/1: visual — export above document res is now full-quality (not upscaled); profile shows - background ≈ 0 and (Stage 1) software export correct at the chosen res. -- Stage 2: headless hardware test in `gpu-video-codec` (decode → wgpu texture, ffprobe/byte checks). -- Stage 3 (user): 1080p + 4K H.264 export — decode/upload buckets collapse; software fallback for a - non-HW codec (e.g. ProRes) still produces correct full-res output. From 9f67a82e3eee391e6a0c1c6db8491696d9eeba94 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 12 Jul 2026 09:39:01 -0400 Subject: [PATCH 02/11] README: update status + add prebuilt-release run instructions Export system and node editor UI are complete; piano roll editor is in progress. Add a "Download a prebuilt release" section (GitHub releases) for users who don't want to build from source, and nest the build-from-source steps under their own heading. --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1f6d5be..c4de207 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,21 @@ Lightningbeam is developed on the `main` branch. The project has been rewritten - ✅ Audio engine with node graph processing - ✅ GPU waveform rendering with mipmaps - ✅ Video decoding integration -- 🚧 Export system (in progress) -- 🚧 Node editor UI (planned) -- 🚧 Piano roll editor (planned) +- ✅ Export system (video, image, audio, animated GIF, SVG) +- ✅ Node editor UI +- 🚧 Piano roll editor (in progress) ## Getting Started -### Prerequisites +### Download a prebuilt release + +If you don't want to build from source, prebuilt binaries for each release are on the +[GitHub releases page](https://github.com/skykooler/lightningbeam/releases). Download the build for +your platform and run it directly. + +### Building from source + +#### Prerequisites - Rust (stable toolchain via [rustup](https://rustup.rs/)) - System dependencies: @@ -74,7 +82,7 @@ Lightningbeam is developed on the `main` branch. The project has been rewritten See [docs/BUILDING.md](docs/BUILDING.md) for detailed setup instructions. -### Building and Running +#### Building and Running ```bash # Clone the repository From f957b01dcf5e74fc2d71bcada6bd93e6d24881bf Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 13 Jul 2026 02:13:06 -0400 Subject: [PATCH 03/11] Cycle recording phase 1: transport loop region Adds a cycle (loop) region: a range on the timeline ruler that the transport wraps at during playback. This is the substrate for GarageBand-style multi-take cycle recording (phases 2 and 3). The region is authored in BEATS so it stays put musically across tempo changes. It lives on the Document (saved in the .beam, serde-defaulted so old files load) and is edited through SetCycleRegionAction, so it is undoable and marks the document modified like any other edit. Backend: - Engine gains loop_region/loop_enabled plus a wrap at the single playhead-advance point in process(). The wrap is phase-preserving (modulo, so an overshoot larger than the loop can't strand the playhead outside the region) and gated on playhead >= 0 so a count-in pre-roll never wraps. - Sounding voices are deliberately NOT reset at the wrap the way Command::Seek does, since that would chop sustain and reverb tails at every pass. - MidiRecordingState::wrap_at_cycle writes note-offs for held notes at the region end and re-opens them at the region start, so a key held across the boundary can't end up with a negative duration or hang. - loop_bounds_frozen freezes the region's sample bounds for the duration of an *audio* recording. Phrased positively on audio so future multi-track recording inherits it: MIDI is beat-segmented and tempo-invariant, but audio is segmented geometrically and we have no time-stretch, so cross-tempo audio takes wouldn't be compable anyway. - Command::Play jumps to loop_start when starting from outside the region; starting inside it plays from where you are. Editor: - Cycle lane along the bottom of the ruler (bottom, so it doesn't cover the bar numbers), painted inside render_ruler under the ticks. Only exists while looping is armed; with cycle off the ruler is entirely the playhead scrubber, as before. - Drag to create/move/resize with a three-zone hit test, previewed locally and committed as ONE action on release. Driven off raw pointer state rather than an egui Response: the lane sits inside the timeline's content response, and a second widget on the same pixels just contests hover every frame. - snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness" parameter instead of a hardcoded constant, with two named profiles: SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser) for the cycle region, so loops land on bars rather than odd subdivisions. - Cycle toggle button using the Lucide repeat glyph. Cargo.lock picks up the 1.0.9-alpha version bump it missed. Co-Authored-By: Claude Opus 4.8 --- daw-backend/src/audio/engine.rs | 141 +++++++ daw-backend/src/audio/recording.rs | 23 ++ daw-backend/src/command/types.rs | 7 + lightningbeam-ui/Cargo.lock | 2 +- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/set_cycle_region.rs | 93 +++++ .../lightningbeam-core/src/document.rs | 13 + .../lightningbeam-editor/src/main.rs | 16 + .../lightningbeam-editor/src/mobile/icons.rs | 1 + .../src/panes/timeline.rs | 369 ++++++++++++++++-- 10 files changed, 643 insertions(+), 24 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs 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)); From 16e3d676d632902d88bf34e68237b42db3c394c3 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 13 Jul 2026 14:40:10 -0400 Subject: [PATCH 04/11] Cycle recording: multi-take capture, take folders, comping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording into a cycle region now produces one take per pass. Pick a take from a badge on the clip; split the clip and pick different takes on the halves, and you've comped. Data model (phase 2): - AudioClipType::TakeFolder { takes, recorded_loop_beats } holds the take list on the CLIP; ClipInstance::active_take holds the selection on the INSTANCE. That split is what makes comping fall out of the existing split action for free — split clones the instance, so the two halves share one take list but choose independently. recorded_loop_beats lets a future time-stretch/conform pass reconcile audio takes if the tempo moves under them. - AudioClip::resolve(active_take) -> ResolvedContent{Audio|Midi|Recording} collapses a take folder to what an instance actually plays. A folder is not a distinct *case* at call sites — it's an audio or MIDI clip whose identity depends on which take is live — so every backend-sync site now resolves through this instead of matching clip_type raw. Reverse lookups go through owns_audio_pool_index/owns_midi_clip_id, since a folder owns one pool file per take, not just the active one. - BackendContext::add_clip_instance/remove_clip_instance: switching takes is a remove + re-add (there's no in-place pool-swap command), and that's the same work AddClipInstanceAction does. One implementation, on the context that already owns the controller and both ID maps, so the seconds-vs-beats conversions can't drift between copies. Capture (phase 3): - Takes are cut GEOMETRICALLY at stop, in exact loop-length multiples. The playhead advances before the capture block in process(), so the wrap instant isn't sample-exact against the buffer just captured — but the geometry is. wrap_count only decides *whether* the recording is multi-take, never where the cuts land. - Partial passes are padded with silence: punch in mid-region and take 1 gets silence prepended back to the region start; stop mid-pass and the last take gets silence appended. Every take is the same length, which is the invariant comping depends on. A final take under 50ms of real audio is dropped as a stop artifact (but a take that FILLED the region never is, however short the region). - MIDI merges, and it falls out for free: anchoring the recording at loop_start rather than the punch-in point means the transport always wraps back INTO the region, so every note's offset already lands inside [0, loop_len) and passes overdub with no folding logic at all. - The whole session commits as ONE undoable action via push_applied. Fixes found on the way: - Split was seconds/beats confused on MIDI. trim_start/trim_end are domain-polymorphic exactly like AudioClip::duration was — SECONDS for audio/video/vector, BEATS for MIDI — and split mapped the split point into clip content in seconds unconditionally. Now it works in the clip's own domain via Document::clip_trim_duration(). Regression test included. - TrimClip took raw f64s whose meaning flipped by track type, and the engine set an AUDIO clip's external_duration = Beats(end - start) where those bounds were SECONDS — so a 1-second split played back as half a second at 120 BPM. Replaced with a domain-tagged TrimRange, built from the clip (clip.trim_range()) so the wrong unit isn't expressible, and the span is now converted at the clip's position on the timeline. - The live preview grew past the loop end while the playhead wrapped. It now grows through the first pass then pins at the region length, and the waveform inside restarts at the region start on each pass. The pass offset is derived from the captured buffer, not the playhead — those advance on different clocks, and differencing them made the waveform jitter horizontally. Co-Authored-By: Claude Opus 4.8 --- daw-backend/src/audio/engine.rs | 202 ++++++++++-- daw-backend/src/audio/recording.rs | 217 +++++++++++++ daw-backend/src/command/mod.rs | 2 +- daw-backend/src/command/types.rs | 38 ++- .../lightningbeam-core/src/action.rs | 117 +++++++ .../src/actions/add_clip_instance.rs | 118 +------ .../src/actions/loop_clip_instances.rs | 10 +- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/move_clip_instances.rs | 20 +- .../src/actions/remove_clip_instances.rs | 10 +- .../src/actions/set_active_take.rs | 117 +++++++ .../src/actions/split_clip_instance.rs | 96 ++++-- .../src/actions/trim_clip_instances.rs | 28 +- .../lightningbeam-core/src/clip.rs | 292 +++++++++++++++++- .../lightningbeam-core/src/document.rs | 23 +- .../lightningbeam-editor/src/main.rs | 129 ++++++++ .../src/panes/asset_library.rs | 74 +++-- .../src/panes/infopanel.rs | 6 + .../src/panes/timeline.rs | 257 ++++++++++++++- 19 files changed, 1518 insertions(+), 240 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b782cd2..74288be 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -543,6 +543,17 @@ impl Engine { rec.wrap_at_cycle(le_beats, ls_beats); } + // An audio recording in progress just completed a pass. This only decides + // *whether* the recording becomes multi-take — the takes themselves are cut + // geometrically at stop, since the playhead advances before the capture + // block below, so the wrap instant isn't sample-exact against the buffer + // that was just captured. The geometry is. + if let Some(ref mut rec) = self.recording_state { + if let Some(ref mut cycle) = rec.cycle { + cycle.wrap_count += 1; + } + } + if let Some(ref mut dr) = self.disk_reader { dr.send(crate::audio::disk_reader::DiskReaderCommand::Seek { frame: self.playhead.max(0) as u64, @@ -944,36 +955,56 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_start = Beats(new_start_time); + clip.external_start = new_start_time; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.id == clip_id) { - instance.external_start = Beats(new_start_time); + instance.external_start = new_start_time; } } _ => {} } self.refresh_clip_snapshot(); } - Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { - // Trim changes which portion of the source content is used - // Also updates external_duration to match internal duration (no looping after trim) - match self.project.get_track_mut(track_id) { - Some(crate::audio::track::TrackNode::Audio(track)) => { + Command::TrimClip(track_id, clip_id, range) => { + // Trim changes which portion of the source content is used. + // Also collapses external_duration to the trimmed content length (no looping after + // a trim). + let tempo_map = self.tempo_map.clone(); + match (self.project.get_track_mut(track_id), range) { + ( + Some(crate::audio::track::TrackNode::Audio(track)), + crate::command::TrimRange::Seconds { start, end }, + ) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.internal_start = Seconds(new_internal_start); - clip.internal_end = Seconds(new_internal_end); - clip.external_duration = Beats(new_internal_end - new_internal_start); + clip.internal_start = start; + clip.internal_end = end; + // external_duration is BEATS while the trims are SECONDS, so the span + // has to be converted at the clip's position on the timeline — NOT + // reinterpreted as `Beats(end - start)`, which played a 1-second trim + // back as half a second at 120 BPM. + clip.external_duration = tempo_map.seconds_to_beats( + tempo_map.beats_to_seconds(clip.external_start) + (end - start), + ) - clip.external_start; } } - Some(crate::audio::track::TrackNode::Midi(track)) => { + ( + Some(crate::audio::track::TrackNode::Midi(track)), + crate::command::TrimRange::Beats { start, end }, + ) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.internal_start = Beats(new_internal_start); - instance.internal_end = Beats(new_internal_end); - instance.external_duration = Beats(new_internal_end - new_internal_start); + instance.internal_start = start; + instance.internal_end = end; + // MIDI content time IS beats, so the span carries over directly. + instance.external_duration = end - start; } } + // A domain that doesn't match the track kind (seconds at a MIDI track, or vice + // versa) is a caller bug, not something to guess at. + (Some(_), _) => { + debug_assert!(false, "TrimClip domain does not match the track kind"); + } _ => {} } self.refresh_clip_snapshot(); @@ -3212,8 +3243,28 @@ impl Engine { self.recording_state = Some(recording_state); self.recording_progress_counter = 0; // Reset progress counter + // Arm cycle recording. `start_time` is the region start (the editor anchors a + // cycle recording there, for punch-in too), while capture actually begins at + // the current playhead — the gap between them is the lead pad that take 1 gets + // prepended as silence so it still spans the whole region. + let cycle_info = if self.loop_enabled { + self.loop_region.map(|(ls_beats, le_beats)| { + let (ls, le) = self.loop_bounds_samples(ls_beats, le_beats); + crate::audio::recording::CycleRecordInfo { + loop_start: ls_beats, + loop_len_beats: le_beats - ls_beats, + loop_len_frames: (le - ls).max(0) as usize, + lead_pad_frames: (self.playhead - ls).max(0) as usize, + wrap_count: 0, + } + }) + } else { + None + }; + // Set samples to skip (drained incrementally across callbacks) if let Some(recording) = &mut self.recording_state { + recording.cycle = cycle_info; recording.samples_to_skip = samples_in_buffer; if self.debug_audio && samples_in_buffer > 0 { eprintln!("[AUDIO DEBUG] Will skip {} stale samples from input buffer", samples_in_buffer); @@ -3238,6 +3289,40 @@ impl Engine { } } + /// Write one cycle take to a temp WAV, add it to the audio pool, and return its pool index. + /// + /// Mirrors what the single-recording path does with its own buffer: the pool file is backed by + /// the in-memory samples, and the temp WAV is written and then removed (the pool only reads the + /// path opportunistically, e.g. to keep original bytes on save). + fn write_take_to_pool( + &mut self, + samples: Vec, + sample_rate: u32, + channels: u32, + clip_id: ClipId, + take_index: usize, + ) -> Result { + use crate::io::WavWriter; + + let path = std::env::temp_dir() + .join(format!("daw_take_{}_{}.wav", clip_id, take_index)); + + let mut writer = WavWriter::create(&path, sample_rate, channels)?; + writer.write_samples(&samples)?; + writer.finalize()?; + + let pool_file = crate::audio::pool::AudioFile::with_format( + path.clone(), + samples, + channels, + sample_rate, + Some("wav".to_string()), + ); + let pool_index = self.audio_pool.add_file(pool_file); + let _ = std::fs::remove_file(&path); + Ok(pool_index) + } + /// Handle stopping a recording fn handle_stop_recording(&mut self) { eprintln!("[STOP_RECORDING] handle_stop_recording called"); @@ -3261,11 +3346,74 @@ impl Engine { eprintln!("[STOP_RECORDING] Stopping recording for clip_id={}, track_id={}", clip_id, track_id); + // Slice cycle takes BEFORE finalize consumes the recording. `None` here means the + // transport never wrapped, which stays an ordinary single recording on the path below. + let cycle = recording.cycle; + let frames_per_peak = recording.frames_per_peak; + let cycle_takes = recording.slice_takes(); + // Finalize the recording (flush buffers, close file, get waveform and audio data) let frames_recorded = recording.frames_written; eprintln!("[STOP_RECORDING] Calling finalize() - frames_recorded={}", frames_recorded); match recording.finalize() { Ok((temp_file_path, waveform, audio_data)) => { + // ---- Cycle recording: one take per pass, each spanning the whole region ---- + if let (Some(takes), Some(cycle)) = (cycle_takes, cycle) { + eprintln!( + "[STOP_RECORDING] Cycle recording: {} wraps -> {} takes of {} frames", + cycle.wrap_count, takes.len(), cycle.loop_len_frames + ); + let _ = std::fs::remove_file(&temp_file_path); + + let mut pool_takes: Vec<(usize, Vec)> = Vec::new(); + for (i, take) in takes.into_iter().enumerate() { + let peaks = crate::audio::recording::compute_peaks( + &take, + channels, + frames_per_peak, + ); + match self.write_take_to_pool(take, sample_rate, channels, clip_id, i) { + Ok(pool_index) => pool_takes.push((pool_index, peaks)), + Err(e) => { + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Failed to store take {}: {}", i + 1, e), + )); + return; + } + } + } + + // Point the engine's clip at the take the editor will make active (the last + // one, GarageBand-style) and stretch it to cover the whole cycle region — + // the clip was created at the punch-in point with zero length. + let loop_len_secs = + Seconds(cycle.loop_len_frames as f64 / sample_rate as f64); + if let Some(&(last_pool_index, _)) = pool_takes.last() { + if let Some(crate::audio::track::TrackNode::Audio(track)) = + self.project.get_track_mut(track_id) + { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) + { + clip.audio_pool_index = last_pool_index; + clip.internal_start = Seconds(0.0); + clip.internal_end = loop_len_secs; + clip.external_start = cycle.loop_start; + clip.external_duration = cycle.loop_len_beats; + } + } + self.refresh_clip_snapshot(); + } + + let _ = self.event_tx.push(AudioEvent::CycleRecordingStopped { + clip_id, + takes: pool_takes, + loop_start: cycle.loop_start, + loop_len_beats: cycle.loop_len_beats, + loop_len_seconds: loop_len_secs, + }); + return; + } + eprintln!("[STOP_RECORDING] Finalize succeeded: {} frames written to {:?}, {} waveform peaks generated, {} samples in memory", frames_recorded, temp_file_path, waveform.len(), audio_data.len()); @@ -3357,7 +3505,13 @@ impl Engine { let track_id = recording.track_id; let notes = recording.get_notes().to_vec(); let note_count = notes.len(); - let recording_duration = end_time - recording.start_time; + // A cycle MIDI recording is anchored at the region start and every pass overdubs into + // the same clip (MERGE), so the clip is exactly one region long — not however long the + // user held the record button, which would run past the loop end. + let recording_duration = match recording.cycle_loop_len { + Some(loop_len) => loop_len, + None => end_time - recording.start_time, + }; eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3} beats", clip_id, track_id, note_count, recording_duration.0); @@ -3522,17 +3676,17 @@ impl EngineController { /// Move a clip to a new timeline position (changes external_start) pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: Beats) { - let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time.beats_to_f64())); + let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); } - /// Trim a clip's internal boundaries (changes which portion of source content is used) - /// This also resets external_duration to match internal duration (disables looping) - /// Trim a clip's internal content bounds. The units are content-domain and depend on the - /// track type: SECONDS for a sampled-audio clip, BEATS for a MIDI clip (see the TrimClip - /// handler). Left as raw f64 because a single newtype can't express both; callers pass the - /// clip's own `trim_start`/`trim_end`, which already match its content domain. - pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { - let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); + /// Trim a clip's internal content bounds — which portion of the source content plays. + /// + /// Collapses external_duration to the trimmed length (disables looping). The bounds are + /// content-domain, which differs by clip kind, so they're passed as a [`TrimRange`] that names + /// the domain: `Seconds` for sampled audio, `Beats` for MIDI. The engine rejects a range whose + /// domain doesn't match the track. + pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, range: crate::command::TrimRange) { + let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, range)); } /// Extend or shrink a clip's external duration (enables looping if > internal duration) diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index f1b3cbb..0a292d0 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -5,6 +5,50 @@ use crate::time::{Beats, Seconds}; use std::collections::HashMap; use std::path::PathBuf; +/// Cycle-recording bookkeeping attached to a recording that started with a cycle region armed. +/// +/// Takes are sliced **geometrically** at stop, in exact `loop_len_frames` multiples — not at the +/// instant the wrap was detected. The playhead advances before the capture block in `process()`, so +/// the wrap instant isn't sample-exact against the buffer that was just captured, but the geometry +/// is. `wrap_count` therefore only decides *whether* this is a multi-take recording, never where the +/// cuts land. +#[derive(Debug, Clone, Copy)] +pub struct CycleRecordInfo { + /// Where the cycle region starts, in beats. Takes are laid down here, not at the punch-in point. + pub loop_start: Beats, + /// The cycle region's length in beats — what the take folder records as `recorded_loop_beats`. + pub loop_len_beats: Beats, + /// One cycle pass, in frames. The take size. + pub loop_len_frames: usize, + /// Frames between the region start and where capture actually began. Non-zero only for a + /// punch-in (record while already rolling); take 1 gets this much silence prepended so it still + /// spans the whole region. + pub lead_pad_frames: usize, + /// How many times the transport wrapped during this recording. Zero means the user stopped + /// before completing a pass, which stays an ordinary single recording. + pub wrap_count: usize, +} + +/// Min/max waveform peaks for a finished buffer of interleaved samples. +/// +/// The live recording path builds its peaks incrementally as samples arrive; cycle takes don't +/// exist until the recording is sliced at stop, so they get theirs in one pass here. +pub fn compute_peaks(samples: &[f32], channels: u32, frames_per_peak: usize) -> Vec { + let samples_per_peak = (frames_per_peak * channels.max(1) as usize).max(1); + samples + .chunks(samples_per_peak) + .map(|chunk| { + let mut min = 0.0f32; + let mut max = 0.0f32; + for s in chunk { + min = min.min(*s); + max = max.max(*s); + } + WaveformPeak { min, max } + }) + .collect() +} + /// State of an active recording session pub struct RecordingState { /// Track being recorded to @@ -35,6 +79,8 @@ pub struct RecordingState { pub frames_per_peak: usize, /// All recorded audio data accumulated in memory (written to disk at finalization) pub audio_data: Vec, + /// Cycle-recording bookkeeping, when a cycle region was armed at record start. + pub cycle: Option, } impl RecordingState { @@ -69,9 +115,69 @@ impl RecordingState { waveform_buffer: Vec::new(), frames_per_peak, audio_data: Vec::new(), + cycle: None, } } + /// Slice the recording into cycle takes: one per pass, each spanning the FULL cycle region. + /// + /// Partial passes are padded with silence — the head of take 1 for a punch-in, the tail of the + /// last take when the user stops mid-pass — so every take is the same length and aligned to the + /// region. That uniformity is what makes comping-via-split work: take 1 on the left half and + /// take 3 on the right always line up. + /// + /// Returns `None` if this wasn't a cycle recording or the transport never wrapped (an ordinary + /// single recording, which keeps the existing path untouched). + pub fn slice_takes(&self) -> Option>> { + let cycle = self.cycle?; + if cycle.wrap_count == 0 || cycle.loop_len_frames == 0 { + return None; + } + + let ch = self.channels.max(1) as usize; + let take_len = cycle.loop_len_frames * ch; + let lead = cycle.lead_pad_frames * ch; + + // The recording as positioned *within the region*: silence for the gap between the region + // start and the punch-in, then the captured audio. Slicing this at whole-take boundaries is + // the whole trick — take 1 comes out short-by-`lead` at the front, already padded. + let virtual_len = lead + self.audio_data.len(); + let take_count = virtual_len.div_ceil(take_len); + + let mut takes: Vec> = Vec::with_capacity(take_count); + for i in 0..take_count { + let mut take = vec![0.0f32; take_len]; + let take_begin = i * take_len; + for slot in 0..take_len { + // Position in the virtual (lead-padded) buffer. + let v = take_begin + slot; + if v < lead { + continue; // still in the punch-in silence + } + match self.audio_data.get(v - lead) { + Some(s) => take[slot] = *s, + None => break, // past the end of capture; the rest stays silent + } + } + takes.push(take); + } + + // A final take holding only a sliver of real audio is a stop artifact (the user hit stop a + // moment after the wrap), not a performance. Drop it — but only if it's actually a PARTIAL + // pass, and never the only take. A pass that filled the region is a real take no matter how + // short the region is. + const MIN_TAKE_SECONDS: f64 = 0.05; + if takes.len() > 1 { + let last_real_samples = virtual_len - (takes.len() - 1) * take_len; + let last_real_seconds = (last_real_samples / ch) as f64 / self.sample_rate as f64; + if last_real_samples < take_len && last_real_seconds < MIN_TAKE_SECONDS { + takes.pop(); + } + } + + Some(takes) + } + /// Add samples to the accumulation buffer /// Returns true if a flush occurred pub fn add_samples(&mut self, samples: &[f32]) -> Result { @@ -189,6 +295,13 @@ pub struct MidiRecordingState { active_notes: HashMap, /// Completed notes: (time_offset, note, velocity, duration) — all times in beats pub completed_notes: Vec<(Beats, u8, u8, Beats)>, + /// The cycle region's length in beats, when recording into a cycle. + /// + /// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is + /// what makes MERGE fall out for free: the transport always wraps back into the region, so every + /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each + /// other with no folding needed. Set only if the transport actually wrapped. + pub cycle_loop_len: Option, } impl MidiRecordingState { @@ -199,6 +312,7 @@ impl MidiRecordingState { start_time, active_notes: HashMap::new(), completed_notes: Vec::new(), + cycle_loop_len: None, } } @@ -283,5 +397,108 @@ impl MidiRecordingState { for (note, velocity) in held { self.note_on(note, velocity, region_start); } + + // The transport wrapped, so this is a cycle recording: the clip spans the whole region + // rather than however long the user happened to hold the record button. + self.cycle_loop_len = Some(region_end - region_start); + } +} + +#[cfg(test)] +mod cycle_tests { + use super::*; + + /// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame + /// is a sample and 5 frames is 50 ms (exactly the min-take threshold). + fn rec(audio: Vec, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { + let mut r = RecordingState::new( + 0, + 0, + PathBuf::from("/dev/null"), + WavWriter::create(&PathBuf::from("/dev/null"), 100, 1).expect("wav writer"), + 100, + 1, + Beats(0.0), + 1.0, + ); + r.audio_data = audio; + r.cycle = Some(CycleRecordInfo { + loop_start: Beats(0.0), + loop_len_beats: Beats(4.0), + loop_len_frames, + lead_pad_frames, + wrap_count: wraps, + }); + r + } + + #[test] + fn no_wrap_is_not_a_cycle_recording() { + // Stopping before the transport ever wraps stays an ordinary single recording — the whole + // point of triggering on the wrap rather than on the cycle region merely existing. + let r = rec(vec![1.0; 10], 4, 0, 0); + assert!(r.slice_takes().is_none()); + } + + #[test] + fn takes_are_cut_at_exact_loop_multiples() { + // 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes. + let audio: Vec = (1..=12).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 0, 2).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 3); + assert_eq!(takes[0], vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); + assert_eq!(takes[2], vec![9.0, 10.0, 11.0, 12.0]); + } + + #[test] + fn punch_in_pads_the_head_of_take_one() { + // Punched in 2 frames into the region: take 1 gets 2 frames of silence at the FRONT so it + // still spans the whole region and lines up with every other take. + let audio: Vec = (1..=10).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 2, 2).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 3); + assert_eq!(takes[0], vec![0.0, 0.0, 1.0, 2.0]); + assert_eq!(takes[1], vec![3.0, 4.0, 5.0, 6.0]); + assert_eq!(takes[2], vec![7.0, 8.0, 9.0, 10.0]); + } + + #[test] + fn stopping_mid_pass_pads_the_tail_of_the_last_take() { + // 13 frames, 8-frame loop => the second take holds 5 real frames (50 ms at 100 Hz, right at + // the keep threshold) and 3 of silence. + let audio: Vec = (1..=13).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 2); + assert_eq!(takes[1], vec![9.0, 10.0, 11.0, 12.0, 13.0, 0.0, 0.0, 0.0]); + } + + #[test] + fn every_take_is_the_same_length() { + // Uniform length is the invariant comping-via-split depends on. + let audio: Vec = (1..=23).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 3, 3).slice_takes().expect("cycle takes"); + assert!(takes.iter().all(|t| t.len() == 8), "takes must be uniform"); + } + + #[test] + fn a_sliver_of_a_final_take_is_dropped() { + // Stopped 1 frame (10 ms at 100 Hz) after the wrap — below the 50 ms floor, so that stub of + // a take is a stop artifact and goes. + let audio: Vec = (1..=9).map(|i| i as f32).collect(); + let takes = rec(audio, 8, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 1, "a 10ms tail take should be dropped"); + assert_eq!(takes[0].len(), 8); + } + + #[test] + fn a_full_final_take_is_never_dropped() { + // Regression: the sliver rule must only fire on a PARTIAL pass. A pass that filled the + // region is a real take however short the region is — an earlier version compared a full + // take's duration to the floor and silently ate it. + let audio: Vec = (1..=8).map(|i| i as f32).collect(); + let takes = rec(audio, 4, 0, 1).slice_takes().expect("cycle takes"); + assert_eq!(takes.len(), 2, "both passes filled the region"); + assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); } } diff --git a/daw-backend/src/command/mod.rs b/daw-backend/src/command/mod.rs index 5baaac2..917ec0c 100644 --- a/daw-backend/src/command/mod.rs +++ b/daw-backend/src/command/mod.rs @@ -1,3 +1,3 @@ pub mod types; -pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse}; +pub use types::{AudioEvent, Command, MidiClipData, OscilloscopeData, Query, QueryResponse, TrimRange}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 4a57152..29a6934 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -8,6 +8,21 @@ use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; use crate::time::{Beats, Seconds}; +/// A clip's internal (content) boundaries, tagged with the domain they're measured in. +/// +/// A clip's content time is SECONDS for sampled audio but BEATS for MIDI — the same polymorphism +/// `ClipInstance::trim_start`/`trim_end` carry. Passing these as bare `f64`s meant the caller and +/// the engine could disagree about the unit with nothing to catch it: an audio trim of "1.0" was +/// once stored as `Beats(1.0)` for the clip's external duration, so a 1-second split played back as +/// half a second at 120 BPM. Tagging the domain makes that a type error instead of a bug report. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TrimRange { + /// Sampled-audio content time. + Seconds { start: Seconds, end: Seconds }, + /// MIDI content time. + Beats { start: Beats, end: Beats }, +} + /// Commands sent from UI/control thread to audio thread #[derive(Debug, Clone)] pub enum Command { @@ -31,10 +46,9 @@ pub enum Command { // Clip management commands /// Move a clip to a new timeline position (track_id, clip_id, new_external_start) - MoveClip(TrackId, ClipId, f64), - /// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) - /// This changes which portion of the source content is used - TrimClip(TrackId, ClipId, f64, f64), + MoveClip(TrackId, ClipId, Beats), + /// Trim a clip's internal boundaries — which portion of the source content is used. + TrimClip(TrackId, ClipId, TrimRange), /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// If duration > internal duration, the clip will loop ExtendClip(TrackId, ClipId, f64), @@ -299,6 +313,22 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// A recording that wrapped the cycle region at least once, and so became multi-take. + /// + /// Each take spans the full region and they're all the same length (partial passes are padded + /// with silence), so the editor can promote the recording clip straight to a take folder. + CycleRecordingStopped { + clip_id: ClipId, + /// One entry per pass: (audio pool index, waveform peaks), in recording order. + takes: Vec<(usize, Vec)>, + /// Where the takes sit on the timeline — the cycle region's start, not the punch-in point. + loop_start: Beats, + /// The region's length in beats (what the take folder stores as `recorded_loop_beats`). + loop_len_beats: Beats, + /// The same length in seconds — the take folder's content duration, which is seconds-domain + /// for audio. + loop_len_seconds: Seconds, + }, /// Recording error (error_message) RecordingError(String), /// MIDI recording stopped (track_id, clip_id, note_count) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 226ddfb..4909e85 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -47,6 +47,123 @@ pub struct BackendContext<'a> { // Future: pub video_controller: Option<&'a mut VideoController>, } +impl BackendContext<'_> { + /// Hand a clip instance to the audio engine and record it in the instance→backend map. + /// + /// Take folders are resolved through the instance's `active_take`, so the backend gets whichever + /// take is selected. Returns the backend track and instance IDs, or `None` when there's nothing + /// to sync yet (a recording in progress, or an empty take folder). + /// + /// Lives here rather than in any one action because more than one action needs it: adding an + /// instance, and switching a take folder's active take (which is a remove + re-add, there being + /// no in-place pool-swap command). Keeping one implementation keeps the trim/duration + /// conversions — the easy thing to get subtly wrong, since `trim_*` is SECONDS while + /// `timeline_*` is BEATS — from drifting between copies. + pub fn add_clip_instance( + &mut self, + document: &Document, + layer_id: &Uuid, + instance: &crate::clip::ClipInstance, + ) -> Result, String> { + use crate::clip::ResolvedContent; + + let clip = document + .get_audio_clip(&instance.clip_id) + .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; + + let track_id = *self + .layer_to_track_map + .get(layer_id) + .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; + + let resolved = clip.resolve(instance.active_take); + let content_duration = clip.content_duration().native(); + let internal_start = instance.trim_start; + let internal_end = instance.trim_end.unwrap_or(content_duration); + let start_time = instance.timeline_start; + + let controller = self + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + + let backend_id = match resolved { + ResolvedContent::Midi { midi_clip_id } => { + use daw_backend::command::{Query, QueryResponse}; + + // MIDI trims are in the BEATS domain, so the fallback span is beats too. + let external_duration = instance + .timeline_duration + .unwrap_or(daw_backend::Beats(internal_end - internal_start)); + + let midi_instance = daw_backend::MidiClipInstance::new( + 0, // assigned by the backend + midi_clip_id, + daw_backend::Beats(internal_start), + daw_backend::Beats(internal_end), + start_time, + external_duration, + ); + + match controller + .send_query(Query::AddMidiClipInstanceSync(track_id, midi_instance))? + { + QueryResponse::MidiClipInstanceAdded(Ok(id)) => BackendClipInstanceId::Midi(id), + QueryResponse::MidiClipInstanceAdded(Err(e)) => return Err(e), + _ => return Err("Unexpected query response".to_string()), + } + } + ResolvedContent::Audio { audio_pool_index } => { + // `trim_*` and the clip's content duration are SECONDS (audio content time); the + // backend's start/duration are BEATS. + // + // When `timeline_duration` is set it's already beats; otherwise the clip occupies + // its natural content length, so convert that seconds-span to beats *at the clip's + // start* (NOT `internal_end - internal_start`, which is seconds — that was the + // seconds-as-beats bug that made clips stop early at anything but 60 BPM). + let effective_duration = instance.timeline_duration.unwrap_or_else(|| { + let tempo_map = document.tempo_map(); + let content_secs = daw_backend::Seconds(internal_end - internal_start); + tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) + - start_time + }); + + let id = controller.add_audio_clip( + track_id, + audio_pool_index, + start_time, + effective_duration, + daw_backend::Seconds(internal_start), + ); + BackendClipInstanceId::Audio(id) + } + // Nothing to sync until it has content. + ResolvedContent::Recording => return Ok(None), + }; + + self.clip_instance_to_backend_map + .insert(instance.id, backend_id); + + Ok(Some((track_id, backend_id))) + } + + /// Remove a clip instance's backend clip and drop it from the instance→backend map. + pub fn remove_clip_instance( + &mut self, + track_id: daw_backend::TrackId, + backend_id: BackendClipInstanceId, + instance_id: Uuid, + ) { + if let Some(controller) = self.audio_controller.as_mut() { + match backend_id { + BackendClipInstanceId::Midi(id) => controller.remove_midi_clip(track_id, id), + BackendClipInstanceId::Audio(id) => controller.remove_audio_clip(track_id, id), + } + } + self.clip_instance_to_backend_map.remove(&instance_id); + } +} + /// Action trait for undo/redo operations /// /// Each action must be able to execute (apply changes) and rollback (undo changes). diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index f39d8ad..fe88754 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -196,113 +196,23 @@ impl Action for AddClipInstanceAction { return Ok(()); } - // Look up the clip from the document - let clip = document - .get_audio_clip(&self.clip_instance.clip_id) - .ok_or_else(|| "Audio clip not found".to_string())?; - - // Look up backend track ID from layer mapping - let backend_track_id = backend - .layer_to_track_map - .get(&self.layer_id) - .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; - - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { - // Create a MIDI clip instance referencing the existing clip in the backend pool - // No need to add to pool again - it was added during MIDI import - use daw_backend::command::{Query, QueryResponse}; - - // Calculate internal start/end from trim parameters - let internal_start = self.clip_instance.trim_start; - let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = self.clip_instance.timeline_start; - - // Calculate external duration (for looping if timeline_duration is set). - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = self.clip_instance.timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - // Create MidiClipInstance - let instance = daw_backend::MidiClipInstance::new( - 0, // Instance ID will be assigned by backend - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - // Send query to add instance and get instance ID - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - // Add to global clip instance mapping - backend.clip_instance_to_backend_map.insert( - self.clip_instance.id, - crate::action::BackendClipInstanceId::Midi(instance_id) - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), + // Add via the shared BackendContext helper — the same one SetActiveTakeAction uses, so + // the trim/duration conversions (and take-folder resolution) live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &self.clip_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) } } - AudioClipType::Sampled { audio_pool_index } => { - // `trim_*` / `clip.duration` are in SECONDS (audio content time), - // while `timeline_*` and the backend's `duration` are in BEATS. - let internal_start = self.clip_instance.trim_start; - let internal_end = self.clip_instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = self.clip_instance.timeline_start; - // `effective_duration` is in BEATS. When `timeline_duration` is set - // it already is; otherwise the clip occupies its natural content - // length, so convert that seconds-span to beats at the clip's start - // (NOT `internal_end - internal_start`, which is seconds — that was - // the seconds-as-beats bug that made clips stop early off 60 BPM). - let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let instance_id = controller.add_audio_clip( - *backend_track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - - self.backend_track_id = Some(*backend_track_id); - self.backend_audio_instance_id = Some(instance_id); - - // Add to global clip instance mapping - backend.clip_instance_to_backend_map.insert( - self.clip_instance.id, - crate::action::BackendClipInstanceId::Audio(instance_id) - ); - - Ok(()) - } - AudioClipType::Recording => { - // Recording clips are not synced to backend until finalized - Ok(()) - } } + + Ok(()) } fn rollback_backend(&mut self, backend: &mut BackendContext, _document: &Document) -> Result<(), String> { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 4e05db1..e8e3099 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -91,7 +91,7 @@ impl Action for LoopClipInstancesAction { impl LoopClipInstancesAction { fn sync_backend(&self, backend: &mut crate::action::BackendContext, document: &Document, rollback: bool) -> Result<(), String> { - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; let controller = match backend.audio_controller.as_mut() { Some(c) => c, @@ -145,9 +145,9 @@ impl LoopClipInstancesAction { let external_start = instance.timeline_start - left_duration; let get_backend_clip_id = |inst_id: &Uuid| -> Result { - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => Ok(*midi_clip_id), - AudioClipType::Sampled { .. } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id), + ResolvedContent::Audio { .. } => { let backend_id = backend.clip_instance_to_backend_map.get(inst_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", inst_id))?; match backend_id { @@ -155,7 +155,7 @@ impl LoopClipInstancesAction { _ => Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => Err("Cannot sync recording clip".to_string()), + ResolvedContent::Recording => Err("Cannot sync recording clip".to_string()), } }; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 83d426f..153b065 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -15,6 +15,7 @@ pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; +pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; pub mod set_layer_properties; @@ -52,6 +53,7 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; +pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; pub use add_shape::AddShapeAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 73f49b2..ceb3777 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -190,7 +190,7 @@ impl Action for MoveClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -246,12 +246,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *new_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -263,7 +263,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be moved - skip } } @@ -275,7 +275,7 @@ impl Action for MoveClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -330,12 +330,12 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type (restore old position) - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *old_start); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: move_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; @@ -347,7 +347,7 @@ impl Action for MoveClipInstancesAction { _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be moved - skip } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index 947e6e0..4415ef6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,7 +138,7 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; let controller = match backend.audio_controller.as_mut() { Some(c) => c, @@ -165,8 +165,8 @@ impl Action for RemoveClipInstancesAction { None => continue, }; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; let internal_start = instance.trim_start; @@ -196,7 +196,7 @@ impl Action for RemoveClipInstancesAction { ); } } - AudioClipType::Sampled { audio_pool_index } => { + ResolvedContent::Audio { audio_pool_index } => { let internal_start = instance.trim_start; let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); let start_time = instance.timeline_start; @@ -221,7 +221,7 @@ impl Action for RemoveClipInstancesAction { BackendClipInstanceId::Audio(new_id), ); } - AudioClipType::Recording => {} + ResolvedContent::Recording => {} } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs new file mode 100644 index 0000000..142d485 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_active_take.rs @@ -0,0 +1,117 @@ +//! Choose which take of a take-folder clip an instance plays. +//! +//! The take list lives on the *clip* but the selection lives on the *instance*, so two instances of +//! the same folder can play different takes. Splitting a take-folder instance clones it, which is +//! what makes comping work: set take 1 on the left half and take 3 on the right, and you've comped. +//! +//! There's no in-place pool-swap command in the backend, so switching a take means removing the +//! instance's backend clip and re-adding it against the new take's audio/MIDI resource. Both halves +//! of that go through `BackendContext`, which is also what `AddClipInstanceAction` uses. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that points a clip instance at a different take of its take folder. +#[derive(Clone)] +pub struct SetActiveTakeAction { + layer_id: Uuid, + instance_id: Uuid, + new_take: Option, + old_take: Option, + /// The backend track/clip the instance was on before we swapped, so rollback can undo it. + backend_track_id: Option, +} + +impl SetActiveTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_take: usize, old_take: Option) -> Self { + Self { + layer_id, + instance_id, + new_take: Some(new_take), + old_take, + backend_track_id: None, + } + } + + /// Point the instance at `take`, mutating the document. Shared by execute and rollback. + fn apply(&self, document: &mut Document, take: Option) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Take folders only exist on audio layers".to_string()); + }; + let instance = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + instance.active_take = take; + Ok(()) + } + + /// Swap the instance's backend clip to whatever take the document now says is active. + /// + /// Called after the document has already been mutated, so re-adding just re-resolves the + /// instance — `BackendContext::add_clip_instance` reads `active_take` itself. + fn resync(&mut self, backend: &mut BackendContext, document: &Document) -> Result<(), String> { + let instance = document + .get_layer(&self.layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + // Drop the old backend clip first. Its track comes from the map we're about to overwrite, + // so read it before add_clip_instance replaces the entry. + let existing: Option = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(&self.layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, self.instance_id); + } + + let added = backend.add_clip_instance(document, &self.layer_id, &instance)?; + self.backend_track_id = added.map(|(track_id, _)| track_id); + Ok(()) + } +} + +impl Action for SetActiveTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + self.apply(document, self.new_take) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + self.apply(document, self.old_take) + } + + fn description(&self) -> String { + match self.new_take { + Some(i) => format!("Select take {}", i + 1), + None => "Select take".to_string(), + } + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index 1e2c6d4..b071d47 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -146,27 +146,37 @@ impl Action for SplitClipInstanceAction { self.original_trim_end = instance.trim_end; self.original_timeline_duration = instance.timeline_duration; - // Check if this is a looping clip. `content_duration` is a trim-domain - // span (seconds), so `clip_duration` must be unwrapped as seconds. + // The clip's content duration in the SAME domain as its trims — seconds for audio/video/ + // vector, beats for MIDI. All the content math below is trim-domain, so it has to be done + // in whichever domain this clip uses; `clip_duration` above is always seconds and would + // silently add a seconds delta to a MIDI clip's beats trim. + let trim_duration = document + .clip_trim_duration(&instance.clip_id) + .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; + let is_looping = instance.timeline_duration.is_some(); - let content_duration = instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()) - instance.trim_start; + let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start; // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content* (seconds, trim domain). + // How far the split lands into the clip's *content*, expressed in the trim domain. let tempo_map = document.tempo_map(); - let time_into_clip_secs = (tempo_map.beats_to_seconds(self.split_time) - - tempo_map.beats_to_seconds(instance.timeline_start)).seconds_to_f64(); + let time_into_content = match trim_duration { + crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), + crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) + - tempo_map.beats_to_seconds(instance.timeline_start)) + .seconds_to_f64(), + }; - // Calculate content split time (seconds) - let content_split_time = if is_looping { + // Calculate the content split point (trim domain). + let content_split_time = if is_looping && content_duration > 0.0 { // For looping clips, wrap around content - instance.trim_start + (time_into_clip_secs % content_duration) + instance.trim_start + (time_into_content % content_duration) } else { - instance.trim_start + time_into_clip_secs + instance.trim_start + time_into_content }; // Clone the instance for the right side @@ -370,9 +380,9 @@ impl Action for SplitClipInstanceAction { .ok_or_else(|| "Audio controller not available".to_string())?; // Handle different clip types - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + use crate::clip::ResolvedContent; + match &clip.resolve(original_instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; // 1. Trim the original (left) instance @@ -383,7 +393,7 @@ impl Action for SplitClipInstanceAction { if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } // 2. Add the new (right) instance @@ -422,7 +432,7 @@ impl Action for SplitClipInstanceAction { _ => Err("Unexpected query response".to_string()), } } - AudioClipType::Sampled { audio_pool_index } => { + ResolvedContent::Audio { audio_pool_index } => { // 1. Trim the original (left) instance let orig_internal_start = original_instance.trim_start; let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); @@ -431,7 +441,7 @@ impl Action for SplitClipInstanceAction { if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(*backend_track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } // 2. Add the new (right) instance @@ -465,7 +475,7 @@ impl Action for SplitClipInstanceAction { Ok(()) } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be split Err("Cannot split a clip that is currently recording".to_string()) } @@ -502,23 +512,23 @@ impl Action for SplitClipInstanceAction { let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); // Restore based on clip type - use crate::clip::AudioClipType; - match &clip.clip_type { - AudioClipType::Midi { .. } => { + use crate::clip::ResolvedContent; + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { .. } => { if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) { - controller.trim_clip(track_id, *orig_backend_id, orig_internal_start, orig_internal_end); + controller.trim_clip(track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips - nothing to rollback } } @@ -575,4 +585,42 @@ mod tests { let action = SplitClipInstanceAction::new(Uuid::new_v4(), Uuid::new_v4(), daw_backend::Beats(5.0)); assert_eq!(action.description(), "Split clip instance"); } + + #[test] + fn splitting_a_midi_clip_stays_in_the_beats_domain() { + // Regression: `trim_start`/`trim_end` are domain-polymorphic — SECONDS for audio/video/ + // vector, but BEATS for MIDI (the backend takes MIDI trims as `Beats`). Split used to map + // the split point into the clip's content in seconds unconditionally, so on a MIDI clip it + // added a seconds delta to a beats offset. At anything but 60 BPM the right half started at + // the wrong place in the content. + // + // At 120 BPM, beat 4 is 2 SECONDS in. The right half must trim to beat 4, not "4 seconds" + // (= beat 8) and not 2 (the seconds value). + let mut document = Document::new("Test"); + document.set_bpm(120.0); + + // 8-beat MIDI clip at the timeline origin. + let clip = crate::clip::AudioClip::new_midi("Midi", 1, daw_backend::Beats(8.0)); + let clip_id = document.add_audio_clip(clip); + + let mut audio_layer = crate::layer::AudioLayer::new("Layer 1"); + let mut instance = ClipInstance::new(clip_id); + instance.timeline_start = daw_backend::Beats::ZERO; + instance.trim_start = 0.0; + instance.trim_end = Some(8.0); // beats + let instance_id = instance.id; + audio_layer.clip_instances.push(instance); + let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer)); + + let mut action = SplitClipInstanceAction::new(layer_id, instance_id, daw_backend::Beats(4.0)); + action.execute(&mut document).expect("split"); + let new_id = action.new_instance_id().expect("right instance"); + + let AnyLayer::Audio(al) = document.get_layer(&layer_id).unwrap() else { panic!() }; + let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap(); + let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap(); + + assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content"); + assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content"); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index d4d48bc..3e3d1be 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -366,7 +366,7 @@ impl Action for TrimClipInstancesAction { fn execute_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -427,24 +427,24 @@ impl Action for TrimClipInstancesAction { let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID - controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); + controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: trim_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); + controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end)); } _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be trimmed - skip } } @@ -456,7 +456,7 @@ impl Action for TrimClipInstancesAction { fn rollback_backend(&mut self, backend: &mut crate::action::BackendContext, document: &Document) -> Result<(), String> { use crate::layer::AnyLayer; - use crate::clip::AudioClipType; + use crate::clip::ResolvedContent; // Get audio controller let controller = match backend.audio_controller.as_mut() { @@ -522,24 +522,24 @@ impl Action for TrimClipInstancesAction { }; // Handle trim based on clip type - match &clip.clip_type { - AudioClipType::Midi { midi_clip_id } => { + match &clip.resolve(instance.active_take) { + ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID - controller.trim_clip(*track_id, *midi_clip_id, internal_start, internal_end); + controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); } - AudioClipType::Sampled { .. } => { + ResolvedContent::Audio { .. } => { // For sampled audio: trim_clip expects the instance ID let backend_instance_id = backend.clip_instance_to_backend_map.get(instance_id) .ok_or_else(|| format!("Clip instance {} not mapped to backend", instance_id))?; match backend_instance_id { crate::action::BackendClipInstanceId::Audio(audio_id) => { - controller.trim_clip(*track_id, *audio_id, internal_start, internal_end); + controller.trim_clip(*track_id, *audio_id, clip.trim_range(internal_start, internal_end)); } _ => return Err("Expected audio instance ID for sampled clip".to_string()), } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording clips cannot be trimmed - skip } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index dd294c4..45459e3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -466,6 +466,44 @@ pub enum AudioClipType { /// Placeholder for a clip that is currently being recorded. /// The audio_pool_index will be assigned when recording stops. Recording, + /// A folder of alternate takes, produced by cycle recording. + /// + /// Each pass of the transport around the cycle region becomes one take. Every take spans the + /// **full** cycle region (partial passes are padded with silence at capture time), so all takes + /// are the same length and share this clip's `duration` — which in turn means switching takes + /// never changes the clip's geometry, and splitting a take-folder instance yields two halves + /// whose takes still line up. Which take actually sounds is per-*instance* + /// ([`ClipInstance::active_take`]), not per-clip, so a split can play take 1 on the left and + /// take 3 on the right. That's comping. + TakeFolder { + /// The takes, in the order they were recorded. Never empty in practice. + takes: Vec, + /// The cycle region's length in beats at the time of recording. + /// + /// Audio takes are segmented geometrically (by sample count), so they're only meaningful + /// against the tempo they were cut at. Keeping the recorded length lets a future + /// time-stretch/conform feature reconcile the takes if the tempo changes underneath them. + recorded_loop_beats: Beats, + }, +} + +/// One take in a [`AudioClipType::TakeFolder`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AudioTake { + /// Display name, e.g. "Take 1". + pub name: String, + /// The recorded content this take points at. + pub content: TakeContent, +} + +/// What a take actually holds. A folder's takes are all the same kind — one cycle-record session +/// captures either audio or MIDI, never a mix. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum TakeContent { + /// Sampled audio: index into the audio pool. + Audio { audio_pool_index: usize }, + /// MIDI: backend MIDI clip ID. + Midi { midi_clip_id: u32 }, } /// A clip's content duration, tagged by its native unit. @@ -532,11 +570,10 @@ impl AudioClip { /// The clip's content duration, tagged with its native domain (seconds for sampled/recording, /// beats for MIDI). This is the only sanctioned way to read the raw `duration` field. pub fn content_duration(&self) -> ClipDuration { - match self.clip_type { - AudioClipType::Midi { .. } => ClipDuration::Beats(Beats(self.duration)), - AudioClipType::Sampled { .. } | AudioClipType::Recording => { - ClipDuration::Seconds(Seconds(self.duration)) - } + if self.is_midi_domain() { + ClipDuration::Beats(Beats(self.duration)) + } else { + ClipDuration::Seconds(Seconds(self.duration)) } } @@ -545,15 +582,29 @@ impl AudioClip { pub fn set_content_duration(&mut self, duration: ClipDuration) { debug_assert!( matches!( - (&self.clip_type, duration), - (AudioClipType::Midi { .. }, ClipDuration::Beats(_)) - | (AudioClipType::Sampled { .. } | AudioClipType::Recording, ClipDuration::Seconds(_)) + (self.is_midi_domain(), duration), + (true, ClipDuration::Beats(_)) | (false, ClipDuration::Seconds(_)) ), "clip duration domain must match clip type", ); self.duration = duration.native(); } + /// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds. + /// + /// A take folder inherits the domain of its takes, which are all the same kind — one + /// cycle-record session captures either audio or MIDI, never a mix. An empty folder can't + /// happen in practice; call it seconds so the fallback is the common case. + fn is_midi_domain(&self) -> bool { + match &self.clip_type { + AudioClipType::Midi { .. } => true, + AudioClipType::Sampled { .. } | AudioClipType::Recording => false, + AudioClipType::TakeFolder { takes, .. } => { + matches!(takes.first().map(|t| &t.content), Some(TakeContent::Midi { .. })) + } + } + } + /// Create a new sampled audio clip /// /// # Arguments @@ -637,6 +688,125 @@ impl AudioClip { _ => None, } } + + /// The clip's takes, if it's a take folder. + pub fn takes(&self) -> Option<&[AudioTake]> { + match &self.clip_type { + AudioClipType::TakeFolder { takes, .. } => Some(takes), + _ => None, + } + } + + /// The take an instance's `active_take` actually selects. + /// + /// `None` means take 0, which is also what an out-of-range index falls back to — an index can + /// go stale (an old `.beam`, an undo that shrank the folder), and silently playing the first + /// take beats refusing to play anything. + fn take_for(&self, active_take: Option) -> Option<&AudioTake> { + let takes = self.takes()?; + takes + .get(active_take.unwrap_or(0)) + .or_else(|| takes.first()) + } + + /// What this clip plays *for a given instance*, with take folders collapsed to the instance's + /// active take. + /// + /// This is the sanctioned way to ask "what content do I hand the backend for this instance?". + /// Matching on `clip_type` directly will see a `TakeFolder` and have to handle it separately; + /// matching on this won't, because a folder is never a distinct case here — it's just an audio + /// or MIDI clip whose identity depends on which take is active. + pub fn resolve(&self, active_take: Option) -> ResolvedContent { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + AudioClipType::Midi { midi_clip_id } => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + AudioClipType::Recording => ResolvedContent::Recording, + AudioClipType::TakeFolder { .. } => match self.take_for(active_take).map(|t| &t.content) { + Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + // An empty folder has nothing to play. Treat it like a recording placeholder: + // the backend gets nothing, rather than a bogus pool index. + None => ResolvedContent::Recording, + }, + } + } + + /// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend. + /// + /// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip — + /// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a + /// caller can't reach for the wrong variant: the clip is the one thing that knows. + pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange { + if self.is_midi_domain() { + daw_backend::command::TrimRange::Beats { + start: Beats(start), + end: Beats(end), + } + } else { + daw_backend::command::TrimRange::Seconds { + start: Seconds(start), + end: Seconds(end), + } + } + } + + /// Whether this clip owns the given audio pool index — either as a plain sampled clip, or as + /// *any* take of a take folder. Reverse lookups (backend resource → document clip) must use + /// this: a folder owns one pool file per take, not just the active one. + pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool { + match &self.clip_type { + AudioClipType::Sampled { audio_pool_index } => *audio_pool_index == pool_index, + AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { + matches!(t.content, TakeContent::Audio { audio_pool_index } if audio_pool_index == pool_index) + }), + _ => false, + } + } + + /// Whether this clip owns the given backend MIDI clip ID. See [`Self::owns_audio_pool_index`]. + pub fn owns_midi_clip_id(&self, id: u32) -> bool { + match &self.clip_type { + AudioClipType::Midi { midi_clip_id } => *midi_clip_id == id, + AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { + matches!(t.content, TakeContent::Midi { midi_clip_id } if midi_clip_id == id) + }), + _ => false, + } + } + + /// The audio pool index this *instance* should play. See [`Self::resolve`]. + pub fn resolved_audio_pool_index(&self, active_take: Option) -> Option { + match self.resolve(active_take) { + ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index), + _ => None, + } + } + + /// The backend MIDI clip ID this *instance* should play. See [`Self::resolve`]. + pub fn resolved_midi_clip_id(&self, active_take: Option) -> Option { + match self.resolve(active_take) { + ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id), + _ => None, + } + } +} + +/// What a clip instance actually plays, once take folders are resolved to their active take. +/// Produced by [`AudioClip::resolve`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResolvedContent { + Audio { audio_pool_index: usize }, + Midi { midi_clip_id: u32 }, + /// A recording in progress (or an empty take folder) — no backend content yet. + Recording, } /// Unified clip enum for polymorphic handling @@ -741,6 +911,15 @@ pub struct ClipInstance { /// Default: None (no pre-loop) #[serde(default, skip_serializing_if = "Option::is_none")] pub loop_before: Option, + + /// Which take of a [`AudioClipType::TakeFolder`] clip this instance plays. + /// + /// Per-instance rather than per-clip so two instances of the same folder — e.g. the two halves + /// of a split — can play different takes. That's how comping works. `None` means take 0; + /// meaningless (and ignored) on non-folder clips. + /// Default: None + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_take: Option, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -799,6 +978,7 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + active_take: None, } } @@ -817,6 +997,7 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + active_take: None, } } @@ -1070,4 +1251,99 @@ mod tests { assert_eq!(instance.playback_speed, 2.0); assert_eq!(instance.gain, 0.8); } + + /// Build a take folder of `n` audio takes with the given pool indices. + fn take_folder(pool_indices: &[usize]) -> AudioClip { + let mut clip = AudioClip::new_sampled("Cycle rec", 0, 2.0); + clip.clip_type = AudioClipType::TakeFolder { + takes: pool_indices + .iter() + .enumerate() + .map(|(i, &audio_pool_index)| AudioTake { + name: format!("Take {}", i + 1), + content: TakeContent::Audio { audio_pool_index }, + }) + .collect(), + recorded_loop_beats: Beats(8.0), + }; + clip + } + + #[test] + fn active_take_selects_the_pool_file() { + let clip = take_folder(&[10, 11, 12]); + assert_eq!(clip.resolved_audio_pool_index(Some(0)), Some(10)); + assert_eq!(clip.resolved_audio_pool_index(Some(2)), Some(12)); + // None means take 0. + assert_eq!(clip.resolved_audio_pool_index(None), Some(10)); + } + + #[test] + fn out_of_range_take_falls_back_to_the_first() { + // An index can go stale (an old .beam, an undo that shrank the folder). Playing the first + // take beats playing nothing. + let clip = take_folder(&[10, 11]); + assert_eq!(clip.resolved_audio_pool_index(Some(99)), Some(10)); + } + + #[test] + fn take_folder_owns_every_takes_pool_file() { + // Reverse lookups (backend resource -> document clip) must find the folder via ANY take, + // not just the active one. + let clip = take_folder(&[10, 11, 12]); + assert!(clip.owns_audio_pool_index(10)); + assert!(clip.owns_audio_pool_index(12)); + assert!(!clip.owns_audio_pool_index(13)); + } + + #[test] + fn midi_take_folder_measures_duration_in_beats() { + // A folder inherits its takes' domain: MIDI takes mean the duration is beats, not seconds. + let mut clip = AudioClip::new_sampled("Cycle rec", 0, 4.0); + clip.clip_type = AudioClipType::TakeFolder { + takes: vec![AudioTake { + name: "Take 1".into(), + content: TakeContent::Midi { midi_clip_id: 7 }, + }], + recorded_loop_beats: Beats(4.0), + }; + assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0))); + assert_eq!(clip.resolved_midi_clip_id(Some(0)), Some(7)); + assert_eq!(clip.resolved_audio_pool_index(Some(0)), None); + } + + #[test] + fn takes_are_per_instance_so_a_split_can_comp() { + // The whole point of putting active_take on the instance: two instances of the same folder + // (which is what a split produces) can play different takes. + let clip = take_folder(&[10, 11, 12]); + let mut left = ClipInstance::new(clip.id); + let mut right = left.clone(); + right.id = Uuid::new_v4(); + left.active_take = Some(0); + right.active_take = Some(2); + + assert_eq!(clip.resolved_audio_pool_index(left.active_take), Some(10)); + assert_eq!(clip.resolved_audio_pool_index(right.active_take), Some(12)); + } + + #[test] + fn clip_instance_without_active_take_deserializes() { + // Back-compat: .beam files written before take folders have no `active_take` field. + let json = r#"{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "clip_id": "550e8400-e29b-41d4-a716-446655440001", + "transform": {"x": 0.0, "y": 0.0, "rotation": 0.0, "scale_x": 1.0, "scale_y": 1.0, "skew_x": 0.0, "skew_y": 0.0}, + "opacity": 1.0, + "name": null, + "timeline_start": 0.0, + "timeline_duration": null, + "trim_start": 0.0, + "trim_end": null, + "playback_speed": 1.0, + "gain": 1.0 + }"#; + let instance: ClipInstance = serde_json::from_str(json).expect("old instances must load"); + assert_eq!(instance.active_take, None); + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 19cb018..0029a75 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -780,16 +780,18 @@ impl Document { } /// Find the document audio clip (UUID + ref) that owns the given backend pool index. + /// A take folder owns one pool file per take, so any of them maps back to the folder. pub fn audio_clip_by_pool_index(&self, pool_index: usize) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.audio_pool_index() == Some(pool_index)) + .find(|(_, c)| c.owns_audio_pool_index(pool_index)) .map(|(&id, c)| (id, c)) } /// Find the document audio clip (UUID + ref) that owns the given backend MIDI clip ID. + /// As above, a take folder owns one MIDI clip per take. pub fn audio_clip_by_midi_clip_id(&self, midi_clip_id: u32) -> Option<(Uuid, &AudioClip)> { self.audio_clips.iter() - .find(|(_, c)| c.midi_clip_id() == Some(midi_clip_id)) + .find(|(_, c)| c.owns_midi_clip_id(midi_clip_id)) .map(|(&id, c)| (id, c)) } @@ -911,6 +913,23 @@ impl Document { /// Searches through all clip libraries to find the clip and return its duration. /// For effect definitions, returns `EFFECT_DURATION` (f64::MAX) since effects /// have infinite internal duration. + /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. + /// + /// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for + /// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as + /// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into + /// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`] + /// can't tell it which: that one always converts to seconds. + /// + /// Returns `None` for unknown clips. + pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option { + if let Some(clip) = self.audio_clips.get(clip_id) { + return Some(clip.content_duration()); + } + // Everything else measures its content in wall-clock seconds. + self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) + } + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a2f1e88..6a35973 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6490,6 +6490,25 @@ impl eframe::App for EditorApp { }) }; + // While cycling, the recording keeps running but the clip only ever + // occupies ONE region — each further pass is a new take layered on + // the same span, not more length. Cap the preview there so the bar + // doesn't grow off past the loop end while the playhead wraps. + let cycle_cap = { + let doc = self.action_executor.document(); + match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => { + let tm = doc.tempo_map(); + Some(tm.beats_to_seconds(le) - tm.beats_to_seconds(ls)) + } + _ => None, + } + }; + let duration = match cycle_cap { + Some(cap) if duration > cap => cap, + _ => duration, + }; + // Then update the clip duration (mutable borrow) if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { @@ -6501,6 +6520,116 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::CycleRecordingStopped { clip_id: backend_clip_id, takes, loop_start, loop_len_beats, loop_len_seconds } => { + eprintln!("[STOP] CycleRecordingStopped: {} takes", takes.len()); + + // Clean up the live-recording waveform cache (keyed usize::MAX). + self.raw_audio_cache.remove(&usize::MAX); + self.waveform_gpu_dirty.remove(&usize::MAX); + + // Pull every take's samples in for waveform rendering — the user can + // switch to any of them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &(pool_index, _) in &takes { + match controller.get_pool_audio_samples(pool_index) { + Ok((samples, sr, ch)) => { + self.raw_audio_cache.insert(pool_index, (Arc::new(samples), sr, ch)); + self.waveform_gpu_dirty.insert(pool_index); + self.audio_pools_with_new_waveforms.insert(pool_index); + } + Err(e) => eprintln!("Failed to fetch take audio: {}", e), + } + self.audio_duration_cache.insert(pool_index, loop_len_seconds.seconds_to_f64()); + } + } + + let recording_layer = self.recording_clips.iter() + .find(|(_, &cid)| cid == backend_clip_id) + .map(|(&lid, _)| lid); + + if let (Some(layer_id), false) = (recording_layer, takes.is_empty()) { + let (clip_id, instance_id) = { + let document = self.action_executor.document(); + document.get_layer(&layer_id) + .and_then(|layer| { + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer { + audio_layer.clip_instances.last().map(|i| (i.clip_id, i.id)) + } else { + None + } + }) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil())) + }; + + if !clip_id.is_nil() { + self.autosave.pending_event = true; + let last_take = takes.len() - 1; + + // Promote the in-progress recording clip to a take folder. + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { + clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { + takes: takes.iter().enumerate().map(|(i, &(pool_index, _))| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + } + }).collect(), + recorded_loop_beats: loop_len_beats, + }; + // Audio takes are seconds-domain, and every take is + // exactly one cycle region long. + clip.set_content_duration(ClipDuration::Seconds(loop_len_seconds)); + clip.name = format!("Cycle recording ({} takes)", takes.len()); + } + + // Anchor the instance to the region and select the most + // recent take, GarageBand-style. + // + // timeline_duration stays None on purpose: pinning it would + // make a later tempo change loop/repeat the take's content + // to fill the span instead of letting it drift naturally. + if let Some(lightningbeam_core::layer::AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + inst.trim_start = 0.0; + inst.trim_end = Some(loop_len_seconds.seconds_to_f64()); + inst.active_take = Some(last_take); + } + } + } + + // The backend already has a clip for the active take (the engine + // pointed it at the last take on stop), so map to it rather than + // adding a duplicate. + let backend_id = lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id); + self.clip_instance_to_backend_map.insert(instance_id, backend_id); + + // Commit the whole cycle-record session as ONE undoable action. + let clip_instance = self.layer_to_track_map.get(&layer_id).copied().and_then(|track_id| { + self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.id == instance_id).cloned() + } else { None }) + .map(|ci| (track_id, ci)) + }); + if let Some((track_id, clip_instance)) = clip_instance { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, clip_instance, track_id, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } else { + self.media_modified = true; + } + } + } + + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + } AudioEvent::RecordingStopped(_backend_clip_id, pool_index, _waveform) => { eprintln!("[STOP] AudioEvent::RecordingStopped received (pool_index={})", pool_index); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index d3d0079..5cfc40c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -7,7 +7,17 @@ //! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::{AudioClipType, VectorClip}; +use lightningbeam_core::clip::{AudioClip, ResolvedContent, VectorClip}; + +/// Library label for an audio clip: a take folder advertises how many takes it holds, anything else +/// just names its kind. The library lists *clips*, not placements, so there's no active take here — +/// a folder is previewed by its first take. +fn take_label(clip: &AudioClip, kind: &str) -> String { + match clip.takes() { + Some(takes) => format!("{} ({} takes)", kind, takes.len()), + None => kind.to_string(), + } +} use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use std::collections::{HashMap, HashSet}; @@ -918,11 +928,11 @@ impl AssetLibraryPane { continue; } - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), - AudioClipType::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), - AudioClipType::Recording => { - // Skip recording-in-progress clips from asset library + let (extra_info, drag_clip_type) = match &clip.resolve(None) { + ResolvedContent::Audio { .. } => (take_label(clip, "Sampled"), DragClipType::AudioSampled), + ResolvedContent::Midi { .. } => (take_label(clip, "MIDI"), DragClipType::AudioMidi), + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) from asset library continue; } }; @@ -1118,15 +1128,15 @@ impl AssetLibraryPane { for (id, clip) in &document.audio_clips { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { - let (extra_info, drag_clip_type) = match &clip.clip_type { - AudioClipType::Sampled { .. } => { - ("Sampled".to_string(), DragClipType::AudioSampled) + let (extra_info, drag_clip_type) = match &clip.resolve(None) { + ResolvedContent::Audio { .. } => { + (take_label(clip, "Sampled"), DragClipType::AudioSampled) } - AudioClipType::Midi { .. } => { - ("MIDI".to_string(), DragClipType::AudioMidi) + ResolvedContent::Midi { .. } => { + (take_label(clip, "MIDI"), DragClipType::AudioMidi) } - AudioClipType::Recording => { - // Skip recording-in-progress clips + ResolvedContent::Recording => { + // Skip recording-in-progress clips (and empty take folders) continue; } }; @@ -1765,7 +1775,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } else { @@ -1790,8 +1800,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve(None) { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { Some(generate_waveform_thumbnail(peaks, bg_color, wave_color)) @@ -1799,7 +1809,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -1807,7 +1817,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { // Recording in progress - show placeholder Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } @@ -2344,8 +2354,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve(None) { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2355,7 +2365,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2363,7 +2373,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2481,8 +2491,8 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { audio_pool_index } => { + match &clip.resolve(None) { + ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); @@ -2492,7 +2502,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let note_color = egui::Color32::from_rgb(100, 200, 100); if let Some(events) = shared.midi_event_cache.get(midi_clip_id) { Some(generate_midi_thumbnail(events, clip.content_duration().native(), bg_color, note_color)) @@ -2500,7 +2510,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -2802,7 +2812,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if waveform.is_some() { @@ -2842,8 +2852,8 @@ impl AssetLibraryPane { // Check if it's sampled or MIDI if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.clip_type { - AudioClipType::Sampled { .. } => { + match &clip.resolve(None) { + ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { println!("✅ Generating waveform thumbnail with {} peaks for asset {}", peaks.len(), asset_id); @@ -2853,7 +2863,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Midi { midi_clip_id } => { + ResolvedContent::Midi { midi_clip_id } => { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); let note_color = egui::Color32::from_rgb(100, 200, 100); @@ -2863,7 +2873,7 @@ impl AssetLibraryPane { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } - AudioClipType::Recording => { + ResolvedContent::Recording => { Some(generate_placeholder_thumbnail(AssetCategory::Audio, 200)) } } @@ -3187,7 +3197,7 @@ impl PaneRenderer for AssetLibraryPane { println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms); let mut invalidated_any = false; for (asset_id, clip) in &document_arc.audio_clips { - if let lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } = &clip.clip_type { + if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) { println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); self.thumbnail_cache.invalidate(asset_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 1522f5b..74f0d78 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1797,10 +1797,16 @@ impl InfopanelPane { ui.label("Name:"); ui.label(&clip.name); }); + let take_count = clip.takes().map(|t| t.len()).unwrap_or(0); + let take_folder_label; let type_name = match &clip.clip_type { lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)", lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)", lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)", + lightningbeam_core::clip::AudioClipType::TakeFolder { .. } => { + take_folder_label = format!("Audio ({} takes)", take_count); + &take_folder_label + } }; ui.horizontal(|ui| { ui.label("Type:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4ec6c21..03eb16d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -286,6 +286,15 @@ pub struct TimelinePane { /// during the last `render_layers`. Used by `handle_input` (next frame) to snap the /// playhead exactly to a keyframe when its diamond is clicked. keyframe_diamond_hits: Vec<(egui::Rect, f64)>, + /// Take-badge click targets recorded during render: (badge rect, layer, instance, active take, + /// take count). Collected while painting, dispatched after — the usual two-phase pattern. + take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>, + /// The take-folder instance whose take menu is open, if any. + open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>, + /// Seconds between the cycle region's start and where the current recording actually began. + /// Zero unless the user punched in mid-region. Used to line the live waveform preview up with + /// the region on each pass. + cycle_record_lead_secs: f64, /// Total duration of the animation duration: f64, @@ -723,6 +732,9 @@ impl TimelinePane { viewport_start_time: 0.0, viewport_scroll_y: 0.0, keyframe_diamond_hits: Vec::new(), + take_badge_hits: Vec::new(), + open_take_menu: None, + cycle_record_lead_secs: 0.0, duration: 10.0, // Default 10 seconds is_scrubbing: false, cycle_drag: None, @@ -1049,7 +1061,41 @@ impl TimelinePane { true }); - let start_time = *shared.playback_time; + let mut start_time = *shared.playback_time; + + // With a cycle region armed, a recording is anchored at the REGION start rather than the + // playhead: every take spans the whole region, so the clip has to as well. + // + // Two ways in. From stopped, we also move the playhead to the region start, so recording + // begins with the loop (the count-in below then rolls in from a measure before it). Punching + // in while already rolling leaves the playhead where it is — the backend prepends silence to + // take 1's head to fill the gap back to the region start. + let cycle_start_secs = { + let doc = shared.action_executor.document(); + match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => { + Some(doc.tempo_map().beats_to_seconds(ls).seconds_to_f64()) + } + _ => None, + } + }; + if let Some(ls_secs) = cycle_start_secs { + // How far into the region we punched in (zero when starting from stopped, since we jump + // the playhead to the region start below). The live waveform preview needs this to know + // where each pass begins inside the recording buffer. + self.cycle_record_lead_secs = if *shared.is_playing { + (*shared.playback_time - ls_secs).max(0.0) + } else { + 0.0 + }; + start_time = ls_secs; + if !*shared.is_playing { + if let Some(controller_arc) = shared.audio_controller { + controller_arc.lock().unwrap().seek(Seconds(ls_secs)); + } + *shared.playback_time = ls_secs; + } + } // Count-in: seek back N beats, start transport + metronome, defer ALL recording commands. // Must happen before Step 4 so no clips or backend recordings are created yet. @@ -1594,6 +1640,101 @@ impl TimelinePane { painter.rect_filled(band, 2.0, fill); } + /// Click handling + dropdown for the take badge painted on take-folder clips. + /// + /// Runs after rendering, off the hit rects collected during it: clicking a badge opens (or + /// closes) a list of the clip's takes, and picking one dispatches `SetActiveTakeAction`. + /// Selection is per-*instance*, so doing this to one half of a split clip and something else to + /// the other half is exactly how you comp. + fn render_take_menu( + &mut self, + ui: &mut egui::Ui, + document: &lightningbeam_core::document::Document, + pending_actions: &mut Vec>, + ) { + let click = ui.input(|i| { + i.pointer + .primary_pressed() + .then(|| i.pointer.interact_pos()) + .flatten() + }); + + if let Some(pos) = click { + if let Some((_, layer_id, instance_id, _, _)) = + self.take_badge_hits.iter().find(|(r, ..)| r.contains(pos)) + { + let key = (*layer_id, *instance_id); + // Clicking the badge of the open menu closes it again. + self.open_take_menu = (self.open_take_menu != Some(key)).then_some(key); + } + } + + let Some((layer_id, instance_id)) = self.open_take_menu else { + return; + }; + // The badge is only in the hit list while it's on screen; if the clip scrolled away, the + // menu has nothing to hang off, so drop it. + let Some((badge, _, _, active, count)) = self + .take_badge_hits + .iter() + .find(|(_, l, i, _, _)| *l == layer_id && *i == instance_id) + .copied() + else { + self.open_take_menu = None; + return; + }; + + // The instance's *stored* selection, which is what rollback must restore — not `active`, + // which is that value clamped for display. + let old_take = document + .get_layer(&layer_id) + .and_then(|l| match l { + lightningbeam_core::layer::AnyLayer::Audio(al) => { + al.clip_instances.iter().find(|ci| ci.id == instance_id) + } + _ => None, + }) + .and_then(|ci| ci.active_take); + + let mut close = false; + let area = egui::Area::new(ui.id().with(("take_menu", instance_id))) + .order(egui::Order::Foreground) + .fixed_pos(egui::pos2(badge.min.x, badge.max.y + 2.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + for i in 0..count { + let is_active = i == active; + if ui + .selectable_label(is_active, format!("Take {}", i + 1)) + .clicked() + { + if !is_active { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetActiveTakeAction::new( + layer_id, + instance_id, + i, + old_take, + ), + )); + } + close = true; + } + } + }); + }); + + // A press anywhere outside the menu (and outside the badge, which toggles) dismisses it. + if let Some(pos) = click { + if !badge.contains(pos) && !area.response.rect.contains(pos) { + close = true; + } + } + if close { + self.open_take_menu = None; + } + } + /// 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 @@ -2896,6 +3037,7 @@ impl TimelinePane { let mut pending_lane_renders: Vec = Vec::new(); // Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek. self.keyframe_diamond_hits.clear(); + self.take_badge_hits.clear(); // Collect video clip rects for hover detection (to avoid borrow conflicts) let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new(); @@ -3801,9 +3943,11 @@ impl TimelinePane { // AUDIO VISUALIZATION: Draw piano roll or waveform overlay if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer { if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) { - match &clip.clip_type { + // Resolve through the instance's active take, so a take folder draws + // whichever take it actually plays. + match &clip.resolve(clip_instance.active_take) { // MIDI: Draw piano roll (with loop iterations) - lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => { + lightningbeam_core::clip::ResolvedContent::Midi { midi_clip_id } => { if let Some(events) = midi_event_cache.get(midi_clip_id) { // Calculate content window for loop detection // preview_clip_duration accounts for TrimLeft/TrimRight drag previews @@ -3862,7 +4006,7 @@ impl TimelinePane { } } // Sampled Audio: Draw waveform via GPU - lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => { + lightningbeam_core::clip::ResolvedContent::Audio { audio_pool_index } => { if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) { // Min/max overview pools: 4 f32/texel at rate sr/B. let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied(); @@ -3991,7 +4135,7 @@ impl TimelinePane { } } // Recording in progress: show live waveform - lightningbeam_core::clip::AudioClipType::Recording => { + lightningbeam_core::clip::ResolvedContent::Recording => { let rec_pool_idx = usize::MAX; if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) { let total_frames = samples.len() / (*ch).max(1) as usize; @@ -4026,6 +4170,36 @@ impl TimelinePane { egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y), ); + // Cycle recording: the clip covers ONE region, but the + // recorded buffer keeps growing across passes. Show the + // *current* pass by offsetting into the buffer to where + // that pass began, so the waveform restarts at the region + // start on each wrap and fills in behind the playhead — + // rather than running on past the clip's end. + // + // The playhead gives the position within the region + // directly, so a punch-in (whose first pass starts partway + // in) needs no extra bookkeeping here. + let mut rec_trim_start = preview_trim_start; + if let (true, Some((ls, le))) = (document.cycle_enabled, document.cycle_region) { + let tm = document.tempo_map(); + let loop_len = (tm.beats_to_seconds(le) + - tm.beats_to_seconds(ls)) + .seconds_to_f64(); + if loop_len > 0.0 { + // Which pass we're on, straight from how much + // audio has been captured. Deriving this from + // the playhead instead would jitter: the + // playhead and the recording buffer advance on + // different clocks, so their difference wobbles + // frame to frame and the waveform slides + // horizontally. + let lead = self.cycle_record_lead_secs; + let pass = ((lead + audio_file_duration) / loop_len).floor(); + rec_trim_start = (pass * loop_len - lead).max(0.0); + } + } + if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 { let instance_id = clip_instance.id.as_u128() as u64; let callback = crate::waveform_gpu::WaveformCallback { @@ -4038,7 +4212,7 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: *sr as f32, clip_start_time: clip_screen_start, - trim_start: preview_trim_start as f32, + trim_start: rec_trim_start as f32, tex_width: crate::waveform_gpu::tex_width() as f32, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -4152,6 +4326,68 @@ impl TimelinePane { ); } } + + // Take badge — "Take 2/4" in the clip's bottom-left, on take-folder clips + // only. Records a hit rect so the click that opens the take menu can be + // dispatched after rendering (the usual two-phase pattern), rather than + // mutating the document mid-paint. + if let Some(take_count) = document + .get_audio_clip(&clip_instance.clip_id) + .and_then(|c| c.takes().map(|t| t.len())) + .filter(|n| *n > 0) + { + let active = clip_instance.active_take.unwrap_or(0).min(take_count - 1); + let label = format!("Take {}/{}", active + 1, take_count); + let text_color = theme.text_color( + &["#timeline", ".take-badge"], + ui.ctx(), + egui::Color32::WHITE, + ); + let galley = painter.layout_no_wrap( + label, + egui::FontId::proportional(10.0), + text_color, + ); + let pad = egui::vec2(4.0, 2.0); + let size = galley.size() + pad * 2.0; + // Bottom-left of the clip, but only when the clip is wide enough that + // the badge wouldn't swamp it. + if clip_rect.width() > size.x + 10.0 && clip_rect.height() > size.y + 4.0 { + let badge = egui::Rect::from_min_size( + egui::pos2( + clip_rect.min.x + 4.0, + clip_rect.max.y - size.y - 3.0, + ), + size, + ); + let hovered = ui + .ctx() + .pointer_hover_pos() + .is_some_and(|p| badge.contains(p)); + let bg = if hovered { + theme.bg_color( + &["#timeline", ".take-badge:hover"], + ui.ctx(), + egui::Color32::from_black_alpha(210), + ) + } else { + theme.bg_color( + &["#timeline", ".take-badge"], + ui.ctx(), + egui::Color32::from_black_alpha(150), + ) + }; + painter.rect_filled(badge, 3.0, bg); + painter.galley(badge.min + pad, galley, text_color); + self.take_badge_hits.push(( + badge, + layer.id(), + clip_instance.id, + active, + take_count, + )); + } + } } } } @@ -4631,7 +4867,12 @@ impl TimelinePane { if !alt_held && !self.is_scrubbing && !self.is_panning { if response.drag_started() { // Use cached mousedown position for edge detection - if let Some(mousedown_pos) = self.mousedown_pos { + if let Some(mousedown_pos) = self + .mousedown_pos + // A press that landed on a take badge is opening the take menu, not grabbing + // the clip it sits on. + .filter(|p| !self.take_badge_hits.iter().any(|(r, ..)| r.contains(*p))) + { if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer( mousedown_pos, document, @@ -6051,6 +6292,8 @@ impl PaneRenderer for TimelinePane { editing_clip_id.as_ref(), ); + self.render_take_menu(ui, document, shared.pending_actions); + // Render automation lanes AFTER handle_input so our ui.interact registers last and wins // egui's interaction priority over handle_input's full-content-area allocation. // All automation lanes use beats as the x-axis; convert via the tempo map. From a5cdbfd0fda42edc2281de2f869cc9bd0177552e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 13 Jul 2026 21:58:36 -0400 Subject: [PATCH 05/11] Type the time domains: no raw f64 in any time-carrying API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in a row came from the same root: a time value crossing an API boundary as a bare f64, with the caller and the callee disagreeing about whether it meant seconds or beats. Recording landed at the wrong time, MIDI clips grew too fast, and a 1-second split played back as half a second. Each was "obviously" one domain at the call site and read as the other on the far side. This makes the mismatch a compile error. Backend API — every time-carrying f64 is gone: - Commands: Seek/SetOffset/SetTrimStart/SetTrimEnd -> Seconds; MoveClip/ ExtendClip/CreateMidiClip/AddMidiNote/AddLoadedMidiClip/ UpdateMidiClipNotes/AddMidiClipSync and all four automation commands -> Beats; TrimClip -> TrimRange. - Events/queries: PlaybackPosition, WaveformChunksReady's time range, AudioFileReady::duration, PoolFileInfo, get_playhead_seconds -> Seconds. - Serialized: MidiClipData::duration and AutomationKeyframeData::time -> Beats. Both newtypes are #[serde(transparent)], so the .beam on-disk format is unchanged. - Several controller methods ALREADY took Beats and unwrapped it to shove into the command — the newtype was being discarded at the very boundary it existed to protect. TrimRange, for the domain-polymorphic case: a clip's content time is SECONDS for sampled audio but BEATS for MIDI, so a single newtype can't express it (there was even a comment in engine.rs saying so, and that rationalization is what let the bug through). A domain-tagged enum can. The engine rejects a range whose domain doesn't match the track, and the range is built from the clip (clip.trim_range()) so callers can't pick the wrong variant. ContentTime, for the trim fields: ClipInstance::trim_start/trim_end are content times, and were the last untyped f64 — the actual root of the split bug. ContentTime is deliberately a DEAD END: no .to_seconds(), no .to_beats(), no arithmetic with Seconds or Beats. Content times combine freely with each other (same clip, same domain — safe), so the ~100 passthrough sites cost nothing; the only exit is resolving against the clip that knows the domain (AudioClip::resolve_content_time / Document::resolve_content_time / ClipDuration::same_domain). Mixing domains no longer compiles. Two more live bugs the types surfaced: - ClipInstance::effective_duration_beats took a SECONDS clip duration and subtracted trim_start from it. For a TRIMMED MIDI clip that subtracted a beats offset from a seconds duration, so the clip's timeline length was wrong at any tempo but 60 BPM. Untrimmed clips happened to work, which is why it hid. It now takes a ClipDuration and resolves in the clip's own domain: beats content carries over directly (tempo- invariant), wall-clock content converts at the clip's position. Regression test asserts a clip trimmed to beats 2..6 is 4 beats long at 60/90/120 BPM. - Trim validation clamped a content-domain trim against a wall-clock gap. gap_to_content/content_to_secs now convert at the clip's position. Also folds two more copies of the backend add-logic into BackendContext::add_clip_instance (split and remove_clip_instances both re-add clips), so the trim/duration conversions live in exactly one place instead of four. Co-Authored-By: Claude Opus 4.8 --- daw-backend/src/audio/engine.rs | 104 +++++---- daw-backend/src/command/types.rs | 45 ++-- daw-backend/src/lib.rs | 2 +- daw-backend/src/time.rs | 44 ++++ daw-backend/src/tui/mod.rs | 2 +- .../lightningbeam-core/src/action.rs | 40 ++-- .../src/actions/add_clip_instance.rs | 5 +- .../src/actions/loop_clip_instances.rs | 18 +- .../src/actions/move_clip_instances.rs | 12 +- .../src/actions/remove_clip_instances.rs | 95 ++------ .../src/actions/split_clip_instance.rs | 206 +++++++----------- .../src/actions/trim_clip_instances.rs | 172 ++++++++++----- .../lightningbeam-core/src/clip.rs | 182 +++++++++++----- .../lightningbeam-core/src/document.rs | 61 ++++-- .../lightningbeam-core/src/effect_layer.rs | 7 +- .../lightningbeam-core/src/hit_test.rs | 18 +- .../lightningbeam-core/src/renderer.rs | 16 +- .../src/export/video_exporter.rs | 7 +- .../lightningbeam-editor/src/main.rs | 24 +- .../src/panes/infopanel.rs | 18 +- .../src/panes/piano_roll.rs | 9 +- .../lightningbeam-editor/src/panes/stage.rs | 27 ++- .../src/panes/timeline.rs | 189 +++++++++------- 23 files changed, 738 insertions(+), 565 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 74288be..1f6cba8 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -572,7 +572,7 @@ impl Engine { if self.frames_since_last_event >= self.event_interval_frames / self.channels as usize { // Clamp to 0 during count-in pre-roll (negative playhead = before project start) - let position_seconds = self.playhead.max(0) as f64 / self.sample_rate as f64; + let position_seconds = Seconds(self.playhead.max(0) as f64 / self.sample_rate as f64); let _ = self .event_tx .push(AudioEvent::PlaybackPosition(position_seconds)); @@ -922,7 +922,7 @@ impl Engine { self.project.stop_all_notes(); } Command::Seek(seconds) => { - self.playhead = (seconds * self.sample_rate as f64) as i64; + self.playhead = (seconds.seconds_to_f64() * self.sample_rate as f64) as i64; // Clamp to 0 for atomic/disk-reader; negative = count-in pre-roll (no disk reads needed) let clamped = self.playhead.max(0) as u64; self.playhead_atomic.store(clamped, Ordering::Relaxed); @@ -1014,12 +1014,12 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.external_duration = Beats(new_external_duration); + clip.external_duration = new_external_duration; } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { - instance.external_duration = Beats(new_external_duration); + instance.external_duration = new_external_duration; } } _ => {} @@ -1046,7 +1046,7 @@ impl Engine { } Command::SetOffset(track_id, offset) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.offset = Seconds(offset); + metatrack.offset = offset; } } Command::SetPitchShift(track_id, semitones) => { @@ -1056,12 +1056,12 @@ impl Engine { } Command::SetTrimStart(track_id, trim_start) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_start = Seconds(trim_start.max(0.0)); + metatrack.trim_start = Seconds(trim_start.seconds_to_f64().max(0.0)); } } Command::SetTrimEnd(track_id, trim_end) => { if let Some(crate::audio::track::TrackNode::Group(metatrack)) = self.project.get_track_mut(track_id) { - metatrack.trim_end = trim_end.map(|t| Seconds(t.max(0.0))); + metatrack.trim_end = trim_end.map(|t| Seconds(t.seconds_to_f64().max(0.0))); } } Command::CreateAudioTrack(name, parent_id) => { @@ -1115,9 +1115,13 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + // A chunk's time_range is a wall-clock span into the audio file. + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); match chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -1181,12 +1185,12 @@ impl Engine { let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); // Create clip content in the pool - let clip = MidiClip::empty(clip_id, Beats(duration), format!("MIDI Clip {}", clip_id)); + let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id)); self.project.midi_clip_pool.add_existing_clip(clip); // Create an instance for this clip on the track let instance_id = self.project.next_midi_clip_instance_id(); - let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, Beats(duration), Beats(start_time)); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { track.clip_instances.push(instance); @@ -1201,11 +1205,11 @@ impl Engine { // Note: clip_id here refers to the clip in the pool, not the instance if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { // Timestamp is in beats (canonical) - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); // Add note off event - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } else { @@ -1214,9 +1218,9 @@ impl Engine { if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { let actual_clip_id = instance.clip_id; if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { - let note_on = MidiEvent::note_on(Beats(time_offset), 0, note, velocity); + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); clip.add_event(note_on); - let note_off_time = Beats(time_offset + duration); + let note_off_time = time_offset + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.add_event(note_off); } @@ -1226,7 +1230,7 @@ impl Engine { } Command::AddLoadedMidiClip(track_id, clip, start_time) => { // Add a pre-loaded MIDI clip to the track with the given start time - if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + if let Ok(_instance_id) = self.project.add_midi_clip_at(track_id, clip, start_time) { // instance positions are already in beats; nothing to sync } self.refresh_clip_snapshot(); @@ -1240,11 +1244,11 @@ impl Engine { // Add new events from the notes array // Timestamps are in beats (canonical) for (start_time, note, velocity, duration) in notes { - let note_on = MidiEvent::note_on(Beats(start_time), 0, note, velocity); + let note_on = MidiEvent::note_on(start_time, 0, note, velocity); clip.events.push(note_on); // Add note off event - let note_off_time = Beats(start_time + duration); + let note_off_time = start_time + duration; let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); clip.events.push(note_off); } @@ -1306,7 +1310,7 @@ impl Engine { } Command::AddAutomationPoint(track_id, lane_id, time, value, curve) => { // Add an automation point to the specified lane - let point = crate::audio::AutomationPoint::new(Beats(time), value, curve); + let point = crate::audio::AutomationPoint::new(time, value, curve); match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { @@ -1332,17 +1336,17 @@ impl Engine { match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Midi(track)) => { if let Some(lane) = track.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } Some(crate::audio::track::TrackNode::Group(group)) => { if let Some(lane) = group.get_automation_lane_mut(lane_id) { - lane.remove_point_at_time(Beats(time), Beats(tolerance)); + lane.remove_point_at_time(time, tolerance); } } None => {} @@ -2431,7 +2435,7 @@ impl Engine { // Downcast to AutomationInputNode using as_any_mut if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { let keyframe = AutomationKeyframe { - time: Beats(time), + time, value, interpolation, ease_out, @@ -2459,7 +2463,7 @@ impl Engine { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(auto_node) = graph_node.node.as_any_mut().downcast_mut::() { - auto_node.remove_keyframe_at_time(Beats(time), Beats(0.001)); // 1ms tolerance + auto_node.remove_keyframe_at_time(time, Beats(0.001)); // 1ms tolerance } else { eprintln!("Node {} is not an AutomationInputNode", node_id); } @@ -2528,9 +2532,12 @@ impl Engine { // Send chunks via MPSC channel (will be forwarded by audio thread) if !chunks.is_empty() { - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks + let event_chunks: Vec<(u32, (Seconds, Seconds), Vec)> = chunks .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) + .map(|chunk| { + let (start, end) = chunk.time_range; + (chunk.chunk_index, (Seconds(start), Seconds(end)), chunk.peaks) + }) .collect(); let _ = chunk_tx.send(AudioEvent::WaveformChunksReady { @@ -2694,7 +2701,7 @@ impl Engine { path: path_str, channels: metadata.channels, sample_rate: metadata.sample_rate, - duration: metadata.duration, + duration: Seconds(metadata.duration), format: metadata.format, }); @@ -2802,7 +2809,7 @@ impl Engine { if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { use crate::command::MidiClipData; QueryResponse::MidiClipData(Ok(MidiClipData { - duration: clip.duration.0, + duration: clip.duration, events: clip.events.clone(), })) } else { @@ -2834,7 +2841,7 @@ impl Engine { InterpolationType::Hold => "hold", }.to_string(); AutomationKeyframeData { - time: kf.time.0, + time: kf.time, value: kf.value, interpolation: interpolation_str, ease_out: kf.ease_out, @@ -3003,7 +3010,11 @@ impl Engine { } Query::GetPoolFileInfo(pool_index) => { match self.audio_pool.get_file_info(pool_index) { - Some(info) => QueryResponse::PoolFileInfo(Ok(info)), + // The pool measures a file's length in wall-clock seconds; name it as such at + // the boundary rather than handing a bare f64 to the UI. + Some((duration, sample_rate, channels)) => { + QueryResponse::PoolFileInfo(Ok((Seconds(duration), sample_rate, channels))) + } None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), } } @@ -3050,7 +3061,7 @@ impl Engine { } Query::AddMidiClipSync(track_id, clip, start_time) => { // Add MIDI clip to track and return the instance ID (positions already in beats) - let result = match self.project.add_midi_clip_at(track_id, clip, crate::time::Beats(start_time)) { + let result = match self.project.add_midi_clip_at(track_id, clip, start_time) { Ok(instance_id) => QueryResponse::MidiClipInstanceAdded(Ok(instance_id)), Err(e) => QueryResponse::MidiClipInstanceAdded(Err(e.to_string())), }; @@ -3644,7 +3655,7 @@ impl EngineController { /// Seek to a specific position in seconds pub fn seek(&mut self, seconds: Seconds) { - let _ = self.command_tx.push(Command::Seek(seconds.seconds_to_f64())); + let _ = self.command_tx.push(Command::Seek(seconds)); } /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -3691,7 +3702,7 @@ impl EngineController { /// Extend or shrink a clip's external duration (enables looping if > internal duration) pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: Beats) { - let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration.beats_to_f64())); + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); } /// Send a generic command to the audio thread @@ -3705,9 +3716,9 @@ impl EngineController { } /// Get current playhead position in seconds - pub fn get_playhead_seconds(&self) -> f64 { + pub fn get_playhead_seconds(&self) -> Seconds { let frames = self.playhead.load(Ordering::Relaxed); - frames as f64 / self.sample_rate as f64 + Seconds(frames as f64 / self.sample_rate as f64) } /// Get the shared clip snapshot. The UI can read this each frame to display @@ -3740,7 +3751,7 @@ impl EngineController { /// Set metatrack time offset in seconds /// Positive = shift content later, negative = shift earlier pub fn set_offset(&mut self, track_id: TrackId, offset: Seconds) { - let _ = self.command_tx.push(Command::SetOffset(track_id, offset.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetOffset(track_id, offset)); } /// Set metatrack pitch shift in semitones (for future use) @@ -3750,12 +3761,12 @@ impl EngineController { /// Set metatrack trim start in seconds pub fn set_trim_start(&mut self, track_id: TrackId, trim_start: Seconds) { - let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start.seconds_to_f64())); + let _ = self.command_tx.push(Command::SetTrimStart(track_id, trim_start)); } /// Set metatrack trim end in seconds (None = no end trim) pub fn set_trim_end(&mut self, track_id: TrackId, trim_end: Option) { - let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end.map(|s| s.seconds_to_f64()))); + let _ = self.command_tx.push(Command::SetTrimEnd(track_id, trim_end)); } /// Create a new audio track @@ -3909,23 +3920,22 @@ impl EngineController { pub fn create_midi_clip(&mut self, track_id: TrackId, start_time: Beats, duration: Beats) -> MidiClipId { // Peek at the next clip ID that will be used let clip_id = self.next_midi_clip_id.load(Ordering::Relaxed); - let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time.beats_to_f64(), duration.beats_to_f64())); + let _ = self.command_tx.push(Command::CreateMidiClip(track_id, start_time, duration)); clip_id } /// Add a MIDI note to a clip pub fn add_midi_note(&mut self, track_id: TrackId, clip_id: MidiClipId, time_offset: Beats, note: u8, velocity: u8, duration: Beats) { - let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset.beats_to_f64(), note, velocity, duration.beats_to_f64())); + let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration)); } /// Add a pre-loaded MIDI clip to a track at the given timeline position (beats) pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: Beats) { - let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time.beats_to_f64())); + let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time)); } /// Update all notes in a MIDI clip. Note tuples are (start [beats], note, velocity, duration [beats]). pub fn update_midi_clip_notes(&mut self, track_id: TrackId, clip_id: MidiClipId, notes: Vec<(Beats, u8, u8, Beats)>) { - let notes = notes.into_iter().map(|(t, n, v, d)| (t.beats_to_f64(), n, v, d.beats_to_f64())).collect(); let _ = self.command_tx.push(Command::UpdateMidiClipNotes(track_id, clip_id, notes)); } @@ -3966,7 +3976,7 @@ impl EngineController { curve: crate::audio::CurveType, ) { let _ = self.command_tx.push(Command::AddAutomationPoint( - track_id, lane_id, time.beats_to_f64(), value, curve, + track_id, lane_id, time, value, curve, )); } @@ -3979,7 +3989,7 @@ impl EngineController { tolerance: Beats, ) { let _ = self.command_tx.push(Command::RemoveAutomationPoint( - track_id, lane_id, time.beats_to_f64(), tolerance.beats_to_f64(), + track_id, lane_id, time, tolerance, )); } @@ -4018,13 +4028,13 @@ impl EngineController { time: Beats, value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32)) { let _ = self.command_tx.push(Command::AutomationAddKeyframe( - track_id, node_id, time.beats_to_f64(), value, interpolation, ease_out, ease_in)); + track_id, node_id, time, value, interpolation, ease_out, ease_in)); } /// Remove a keyframe from an AutomationInput node pub fn automation_remove_keyframe(&mut self, track_id: TrackId, node_id: u32, time: Beats) { let _ = self.command_tx.push(Command::AutomationRemoveKeyframe( - track_id, node_id, time.beats_to_f64())); + track_id, node_id, time)); } /// Set the display name of an AutomationInput node @@ -4560,7 +4570,7 @@ impl EngineController { } /// Get file info from pool (duration, sample_rate, channels) - pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(f64, u32, u32), String> { + pub fn get_pool_file_info(&mut self, pool_index: usize) -> Result<(Seconds, u32, u32), String> { // Send query if let Err(_) = self.query_tx.push(Query::GetPoolFileInfo(pool_index)) { return Err("Failed to send query - queue full".to_string()); diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 29a6934..e14a19b 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -34,7 +34,7 @@ pub enum Command { /// Pause playback (maintains position) Pause, /// Seek to a specific position in seconds - Seek(f64), + Seek(Seconds), // Track management commands /// Set track volume (0.0 = silence, 1.0 = unity gain) @@ -51,7 +51,7 @@ pub enum Command { TrimClip(TrackId, ClipId, TrimRange), /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) /// If duration > internal duration, the clip will loop - ExtendClip(TrackId, ClipId, f64), + ExtendClip(TrackId, ClipId, Beats), // Metatrack management commands /// Create a new metatrack with a name and optional parent group @@ -67,15 +67,15 @@ pub enum Command { SetTimeStretch(TrackId, f32), /// Set metatrack time offset in seconds (track_id, offset) /// Positive = shift content later, negative = shift earlier - SetOffset(TrackId, f64), + SetOffset(TrackId, Seconds), /// Set metatrack pitch shift in semitones (track_id, semitones) - for future use SetPitchShift(TrackId, f32), /// Set metatrack trim start in seconds (track_id, trim_start) /// Children won't hear content before this point - SetTrimStart(TrackId, f64), + SetTrimStart(TrackId, Seconds), /// Set metatrack trim end in seconds (track_id, trim_end) /// None means no end trim - SetTrimEnd(TrackId, Option), + SetTrimEnd(TrackId, Option), // Audio track commands /// Create a new audio track with a name and optional parent group @@ -94,14 +94,14 @@ pub enum Command { /// Add a MIDI clip to the pool without placing it on a track AddMidiClipToPool(MidiClip), /// Create a new MIDI clip on a track (track_id, start_time, duration) - CreateMidiClip(TrackId, f64, f64), + CreateMidiClip(TrackId, Beats, Beats), /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) - AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), + AddMidiNote(TrackId, MidiClipId, Beats, u8, u8, Beats), /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) - AddLoadedMidiClip(TrackId, MidiClip, f64), + AddLoadedMidiClip(TrackId, MidiClip, Beats), /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes - UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), + UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(Beats, u8, u8, Beats)>), /// Replace all events in a MIDI clip (track_id, clip_id, events). Used for CC/pitch bend editing. UpdateMidiClipEvents(TrackId, MidiClipId, Vec), /// Remove a MIDI clip instance from a track (track_id, instance_id) - for undo/redo support @@ -117,9 +117,9 @@ pub enum Command { /// Create a new automation lane on a track (track_id, parameter_id) CreateAutomationLane(TrackId, ParameterId), /// Add an automation point to a lane (track_id, lane_id, time, value, curve) - AddAutomationPoint(TrackId, AutomationLaneId, f64, f32, CurveType), + AddAutomationPoint(TrackId, AutomationLaneId, Beats, f32, CurveType), /// Remove an automation point at a specific time (track_id, lane_id, time, tolerance) - RemoveAutomationPoint(TrackId, AutomationLaneId, f64, f64), + RemoveAutomationPoint(TrackId, AutomationLaneId, Beats, Beats), /// Clear all automation points from a lane (track_id, lane_id) ClearAutomationLane(TrackId, AutomationLaneId), /// Remove an automation lane (track_id, lane_id) @@ -258,9 +258,9 @@ pub enum Command { // Automation Input Node commands /// Add or update a keyframe on an AutomationInput node (track_id, node_id, time, value, interpolation, ease_out, ease_in) - AutomationAddKeyframe(TrackId, u32, f64, f32, String, (f32, f32), (f32, f32)), + AutomationAddKeyframe(TrackId, u32, Beats, f32, String, (f32, f32), (f32, f32)), /// Remove a keyframe from an AutomationInput node (track_id, node_id, time) - AutomationRemoveKeyframe(TrackId, u32, f64), + AutomationRemoveKeyframe(TrackId, u32, Beats), /// Set the display name of an AutomationInput node (track_id, node_id, name) AutomationSetName(TrackId, u32, String), @@ -292,7 +292,7 @@ pub enum Command { #[derive(Debug, Clone)] pub enum AudioEvent { /// Current playback position in seconds - PlaybackPosition(f64), + PlaybackPosition(Seconds), /// Playback has stopped (reached end of audio) PlaybackStopped, /// Audio buffer underrun detected @@ -379,7 +379,7 @@ pub enum AudioEvent { WaveformChunksReady { pool_index: usize, detail_level: u8, - chunks: Vec<(u32, (f64, f64), Vec)>, + chunks: Vec<(u32, (Seconds, Seconds), Vec)>, }, /// An audio file has been imported and is ready for playback. @@ -390,7 +390,7 @@ pub enum AudioEvent { path: String, channels: u32, sample_rate: u32, - duration: f64, + duration: Seconds, format: crate::io::audio_file::AudioFormat, }, @@ -464,7 +464,7 @@ pub enum Query { /// Export audio to file (settings, output_path) ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), /// Add a MIDI clip to a track synchronously (track_id, clip, start_time) - returns instance ID - AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, f64), + AddMidiClipSync(TrackId, crate::audio::midi::MidiClip, Beats), /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// The clip must already exist in the MidiClipPool AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), @@ -509,16 +509,21 @@ pub struct OscilloscopeData { } /// MIDI clip data for serialization +/// +/// `Beats`/`Seconds` are `#[serde(transparent)]`, so naming the domain here costs nothing on disk — +/// the `.beam` still holds a plain number. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct MidiClipData { - pub duration: f64, + /// MIDI content length is musical, so beats. + pub duration: Beats, pub events: Vec, } /// Automation keyframe data for serialization #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AutomationKeyframeData { - pub time: f64, + /// Automation x-axes are all beats. + pub time: Beats, pub value: f32, pub interpolation: String, pub ease_out: (f32, f32), @@ -555,7 +560,7 @@ pub enum QueryResponse { /// Pool waveform data PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) - PoolFileInfo(Result<(f64, u32, u32), String>), + PoolFileInfo(Result<(Seconds, u32, u32), String>), /// Audio exported AudioExported(Result<(), String>), /// MIDI clip instance added (returns instance ID) diff --git a/daw-backend/src/lib.rs b/daw-backend/src/lib.rs index a8153e6..c258ca9 100644 --- a/daw-backend/src/lib.rs +++ b/daw-backend/src/lib.rs @@ -20,7 +20,7 @@ pub use audio::{ TrackNode, }; pub use audio::node_graph::{GraphPreset, AudioGraph, PresetMetadata, SerializedConnection, SerializedNode}; -pub use time::{Beats, Seconds}; +pub use time::{Beats, ContentTime, Seconds}; pub use tempo_map::{TempoEntry, TempoInterpolation, TempoMap, beats_to_seconds_stack, seconds_to_beats_stack}; pub use command::{AudioEvent, Command, OscilloscopeData}; pub use command::types::AutomationKeyframeData; diff --git a/daw-backend/src/time.rs b/daw-backend/src/time.rs index b2e2913..fdea4f4 100644 --- a/daw-backend/src/time.rs +++ b/daw-backend/src/time.rs @@ -16,6 +16,50 @@ pub struct Beats(pub f64); #[serde(transparent)] pub struct Seconds(pub f64); +/// A time *inside a clip's own content*, in whatever unit that clip measures content in. +/// +/// Clip content time is domain-polymorphic: SECONDS for sampled audio, video and vector, but BEATS +/// for MIDI (musical, so it survives tempo changes). `ClipInstance::trim_start`/`trim_end` are +/// content times, and storing them as bare `f64`s is what let a seconds delta get added to a MIDI +/// clip's beats trim — splitting a MIDI clip at beat 4 landed at beat 2 at 120 BPM. +/// +/// This type is deliberately a **dead end**: it has no `.to_seconds()`, no `.to_beats()`, and no +/// arithmetic with `Seconds` or `Beats`. Content times can be compared and combined with each other +/// (that's domain-safe — both operands are in the same clip's domain), but the only way to get a +/// real timeline duration out is to resolve it against the clip that knows the domain, via +/// `AudioClip::resolve_content_time` / `Document::resolve_content_time`. So a passthrough costs +/// nothing, and mixing domains won't compile. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ContentTime(pub f64); + +impl ContentTime { + pub const ZERO: Self = Self(0.0); + + pub fn max(self, other: Self) -> Self { Self(self.0.max(other.0)) } + pub fn min(self, other: Self) -> Self { Self(self.0.min(other.0)) } + + /// The raw magnitude, with the domain discarded. + /// + /// Only for code that is *already* working in this clip's content domain (trim arithmetic, + /// serialization, drawing a waveform whose x-axis is the clip's own content). If you are about + /// to combine this with a timeline position, resolve it against the clip instead. + pub fn raw(self) -> f64 { self.0 } +} + +impl Add for ContentTime { + type Output = Self; + fn add(self, rhs: Self) -> Self { Self(self.0 + rhs.0) } +} +impl Sub for ContentTime { + type Output = Self; + fn sub(self, rhs: Self) -> Self { Self(self.0 - rhs.0) } +} +impl Rem for ContentTime { + type Output = Self; + fn rem(self, rhs: Self) -> Self { Self(self.0 % rhs.0) } +} + impl Beats { pub const ZERO: Self = Self(0.0); diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index 0d44a6b..645c699 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -556,7 +556,7 @@ pub fn run_tui( while let Ok(event) = rx.pop() { match event { AudioEvent::PlaybackPosition(pos) => { - app.update_playback_position(pos); + app.update_playback_position(pos.seconds_to_f64()); } AudioEvent::PlaybackStopped => { app.set_playing(false); diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 4909e85..4ac3547 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -77,11 +77,18 @@ impl BackendContext<'_> { .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; let resolved = clip.resolve(instance.active_take); - let content_duration = clip.content_duration().native(); + let content = clip.content_duration(); let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(content_duration); + let internal_end = instance + .trim_end + .unwrap_or(daw_backend::ContentTime(content.native())); let start_time = instance.timeline_start; + // How long the clip occupies the timeline, in BEATS. `effective_duration_beats` resolves the + // content window in the clip's own domain — beats content carries over directly, wall-clock + // content converts at the clip's position — so neither kind can be read as the other here. + let effective_duration = instance.effective_duration_beats(content, document.tempo_map()); + let controller = self .audio_controller .as_mut() @@ -91,18 +98,14 @@ impl BackendContext<'_> { ResolvedContent::Midi { midi_clip_id } => { use daw_backend::command::{Query, QueryResponse}; - // MIDI trims are in the BEATS domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - + // MIDI content time IS beats, so the trims carry straight over. let midi_instance = daw_backend::MidiClipInstance::new( 0, // assigned by the backend midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), + daw_backend::Beats(internal_start.raw()), + daw_backend::Beats(internal_end.raw()), start_time, - external_duration, + effective_duration, ); match controller @@ -114,26 +117,13 @@ impl BackendContext<'_> { } } ResolvedContent::Audio { audio_pool_index } => { - // `trim_*` and the clip's content duration are SECONDS (audio content time); the - // backend's start/duration are BEATS. - // - // When `timeline_duration` is set it's already beats; otherwise the clip occupies - // its natural content length, so convert that seconds-span to beats *at the clip's - // start* (NOT `internal_end - internal_start`, which is seconds — that was the - // seconds-as-beats bug that made clips stop early at anything but 60 BPM). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - + // Sampled-audio content time is SECONDS; the backend's start/duration are BEATS. let id = controller.add_audio_clip( track_id, audio_pool_index, start_time, effective_duration, - daw_backend::Seconds(internal_start), + daw_backend::Seconds(internal_start.raw()), ); BackendClipInstanceId::Audio(id) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index fe88754..e7aa768 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -89,10 +89,11 @@ impl Action for AddClipInstanceAction { // `get_clip_duration` is the content length in seconds; the placement span // must be beats (the timeline is beats-domain), so convert via the clip's // typed helper rather than treating the seconds span as beats. - let clip_duration = document.get_clip_duration(&self.clip_instance.clip_id) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&self.clip_instance.clip_id) .ok_or_else(|| format!("Clip {} not found", self.clip_instance.clip_id))?; let effective_duration = self.clip_instance - .effective_duration_beats(clip_duration, document.tempo_map()); + .effective_duration_beats(clip_content, document.tempo_map()); // Auto-adjust position for audio/video layers to avoid overlaps let adjusted_start = document.find_nearest_valid_position( diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index e8e3099..a68795a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -128,17 +128,13 @@ impl LoopClipInstancesAction { (new_dur, new_lb) }; - let content_window = { - let trim_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - (trim_end - instance.trim_start).max(0.0) // seconds - }; - // Natural content length as a beats span at the clip's start (the - // fallback when no explicit timeline_duration is set). - let tempo_map = document.tempo_map(); - let content_window_beats = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) - + daw_backend::Seconds(content_window), - ) - instance.timeline_start; + // Natural content length as a beats span (the fallback when no explicit + // timeline_duration is set). Resolved in the clip's own domain, so MIDI's beats + // content carries over directly rather than being read as seconds. + let content_window_beats = instance.effective_duration_beats( + clip.content_duration(), + document.tempo_map(), + ); let right_duration = target_duration.unwrap_or(content_window_beats); let left_duration = target_loop_before.unwrap_or(daw_backend::Beats::ZERO); let external_duration = left_duration + right_duration; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index ceb3777..0439e04 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -105,7 +105,7 @@ impl Action for MoveClipInstancesAction { let group: Vec<(Uuid, Beats, Beats)> = moves.iter().filter_map(|(id, old_start, _)| { let inst = clip_instances.iter().find(|ci| &ci.id == id)?; - let dur = document.get_clip_duration(&inst.clip_id)?; + let dur = document.clip_trim_duration(&inst.clip_id)?; let eff = inst.effective_duration_beats(dur, document.tempo_map()); Some((*id, *old_start, eff)) }).collect(); @@ -211,8 +211,9 @@ impl Action for MoveClipInstancesAction { // Check if this clip has a metatrack if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*new_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -295,8 +296,9 @@ impl Action for MoveClipInstancesAction { if let Some(instance) = vl.clip_instances.iter().find(|ci| ci.id == *instance_id) { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(*old_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index 4415ef6..319e397 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -138,92 +138,23 @@ impl Action for RemoveClipInstancesAction { backend: &mut BackendContext, document: &Document, ) -> Result<(), String> { - use crate::clip::ResolvedContent; + if backend.audio_controller.is_none() { + return Ok(()); + } - let controller = match backend.audio_controller.as_mut() { - Some(c) => c, - None => return Ok(()), - }; - - // Re-add clips that were removed from backend - for (layer_id, instance) in &self.saved { - let layer = match document.get_layer(layer_id) { - Some(l) => l, - None => continue, - }; - if !matches!(layer, AnyLayer::Audio(_)) { + // Re-add the clips that were removed. `BackendContext::add_clip_instance` is the same + // helper the add and split actions use, so the trim/duration conversions (and take-folder + // resolution) stay in exactly one place instead of being copied into every action that has + // to put a clip back. + let saved = std::mem::take(&mut self.saved); + for (layer_id, instance) in &saved { + if !matches!(document.get_layer(layer_id), Some(AnyLayer::Audio(_))) { continue; } - - let track_id = match backend.layer_to_track_map.get(layer_id) { - Some(id) => *id, - None => continue, - }; - - let clip = match document.get_audio_clip(&instance.clip_id) { - Some(c) => c, - None => continue, - }; - - match &clip.resolve(instance.active_take) { - ResolvedContent::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let midi_instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(track_id, midi_instance); - if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) = - controller.send_query(query) - { - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Midi(new_id), - ); - } - } - ResolvedContent::Audio { audio_pool_index } => { - let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let new_id = controller.add_audio_clip( - track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - backend.clip_instance_to_backend_map.insert( - instance.id, - BackendClipInstanceId::Audio(new_id), - ); - } - ResolvedContent::Recording => {} - } + // A missing track/clip just means there's nothing to restore on the backend. + let _ = backend.add_clip_instance(document, layer_id, instance); } + self.saved = saved; // Clear saved backend IDs self.saved_backend_ids.clear(); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index b071d47..c234510 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -7,6 +7,7 @@ use crate::action::{Action, BackendContext}; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; +use daw_backend::ContentTime; use uuid::Uuid; /// Action that splits a clip instance at a specific timeline position @@ -25,7 +26,7 @@ pub struct SplitClipInstanceAction { // Stored during execute for rollback /// Original trim_end value of the left (original) instance - original_trim_end: Option, + original_trim_end: Option, /// Original timeline_duration value of the left (original) instance (beats) original_timeline_duration: Option, /// ID of the new (right) instance created by the split @@ -122,13 +123,15 @@ impl Action for SplitClipInstanceAction { .find(|ci| ci.id == self.instance_id) .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; - // Get the clip's duration - let clip_duration = document - .get_clip_duration(&instance.clip_id) + // The clip's content duration in its OWN domain — seconds for audio/video/vector, beats for + // MIDI. All the content math below is trim-domain, so it has to be done in whichever domain + // this clip uses; a seconds duration would silently be added to a MIDI clip's beats trim. + let trim_duration = document + .clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; // Calculate the effective duration and timeline end (both in beats) - let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); + let effective_duration = instance.effective_duration(trim_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; // Validate: split_time must be strictly within the clip's timeline span @@ -146,33 +149,26 @@ impl Action for SplitClipInstanceAction { self.original_trim_end = instance.trim_end; self.original_timeline_duration = instance.timeline_duration; - // The clip's content duration in the SAME domain as its trims — seconds for audio/video/ - // vector, beats for MIDI. All the content math below is trim-domain, so it has to be done - // in whichever domain this clip uses; `clip_duration` above is always seconds and would - // silently add a seconds delta to a MIDI clip's beats trim. - let trim_duration = document - .clip_trim_duration(&instance.clip_id) - .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; - let is_looping = instance.timeline_duration.is_some(); - let content_duration = instance.trim_end.unwrap_or(trim_duration.native()) - instance.trim_start; + let content_duration = ContentTime(instance.content_window(trim_duration).native()); // Timeline split point (beats). let time_into_clip = self.split_time - instance.timeline_start; let left_duration = time_into_clip; let right_duration = effective_duration - left_duration; - // How far the split lands into the clip's *content*, expressed in the trim domain. + // How far the split lands into the clip's *content*, expressed in the content domain: beats + // content takes the beats delta directly, wall-clock content takes the seconds delta. let tempo_map = document.tempo_map(); - let time_into_content = match trim_duration { + let time_into_content = ContentTime(match trim_duration { crate::clip::ClipDuration::Beats(_) => time_into_clip.beats_to_f64(), crate::clip::ClipDuration::Seconds(_) => (tempo_map.beats_to_seconds(self.split_time) - tempo_map.beats_to_seconds(instance.timeline_start)) .seconds_to_f64(), - }; + }); - // Calculate the content split point (trim domain). - let content_split_time = if is_looping && content_duration > 0.0 { + // Calculate the content split point (content domain). + let content_split_time = if is_looping && content_duration > ContentTime::ZERO { // For looping clips, wrap around content instance.trim_start + (time_into_content % content_duration) } else { @@ -367,119 +363,63 @@ impl Action for SplitClipInstanceAction { .get_audio_clip(&new_instance.clip_id) .ok_or_else(|| "Audio clip not found".to_string())?; - // Look up backend track ID from layer mapping - let backend_track_id = backend + use crate::clip::ResolvedContent; + if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) { + return Err("Cannot split a clip that is currently recording".to_string()); + } + + // A split is: shorten the left half's backend clip, then add the right half as a new one. + // + // 1. Trim the left (original) instance. `trim_range` tags the bounds with the clip's own + // content domain, so a MIDI clip's beats trims can't be sent as seconds. + let left_trim = clip.trim_range( + original_instance.trim_start, + original_instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())), + ); + let new_instance = new_instance.clone(); + + let backend_track_id = *backend .layer_to_track_map .get(&self.layer_id) .ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?; + let left_backend_id = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); - // Get audio controller - let controller = backend - .audio_controller - .as_mut() - .ok_or_else(|| "Audio controller not available".to_string())?; - - // Handle different clip types - use crate::clip::ResolvedContent; - match &clip.resolve(original_instance.active_take) { - ResolvedContent::Midi { midi_clip_id } => { - use daw_backend::command::{Query, QueryResponse}; - - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); + { + let controller = backend + .audio_controller + .as_mut() + .ok_or_else(|| "Audio controller not available".to_string())?; + match left_backend_id { + Some(crate::action::BackendClipInstanceId::Midi(id)) + | Some(crate::action::BackendClipInstanceId::Audio(id)) => { + controller.trim_clip(backend_track_id, id, left_trim); } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let external_start = new_instance.timeline_start; - // MIDI trims are beats-domain, so the fallback span is beats too. - let external_duration = new_instance - .timeline_duration - .unwrap_or(daw_backend::Beats(internal_end - internal_start)); - - let instance = daw_backend::MidiClipInstance::new( - 0, - *midi_clip_id, - daw_backend::Beats(internal_start), - daw_backend::Beats(internal_end), - external_start, - external_duration, - ); - - let query = Query::AddMidiClipInstanceSync(*backend_track_id, instance); - - match controller.send_query(query)? { - QueryResponse::MidiClipInstanceAdded(Ok(instance_id)) => { - self.backend_track_id = Some(*backend_track_id); - self.backend_midi_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Midi(instance_id), - ); - - Ok(()) - } - QueryResponse::MidiClipInstanceAdded(Err(e)) => Err(e), - _ => Err("Unexpected query response".to_string()), - } - } - ResolvedContent::Audio { audio_pool_index } => { - // 1. Trim the original (left) instance - let orig_internal_start = original_instance.trim_start; - let orig_internal_end = original_instance.trim_end.unwrap_or(clip.content_duration().native()); - - // Look up the original backend instance ID - if let Some(crate::action::BackendClipInstanceId::Audio(orig_backend_id)) = - backend.clip_instance_to_backend_map.get(&self.instance_id) - { - controller.trim_clip(*backend_track_id, *orig_backend_id, clip.trim_range(orig_internal_start, orig_internal_end)); - } - - // 2. Add the new (right) instance - let internal_start = new_instance.trim_start; - let internal_end = new_instance.trim_end.unwrap_or(clip.content_duration().native()); - let start_time = new_instance.timeline_start; - // Fallback span is the content seconds converted to beats at the - // clip's start (not the seconds span treated as beats). - let effective_duration = new_instance.timeline_duration.unwrap_or_else(|| { - let tempo_map = document.tempo_map(); - let content_secs = daw_backend::Seconds(internal_end - internal_start); - tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(start_time) + content_secs) - - start_time - }); - - let instance_id = controller.add_audio_clip( - *backend_track_id, - *audio_pool_index, - start_time, - effective_duration, - daw_backend::Seconds(internal_start), - ); - - self.backend_track_id = Some(*backend_track_id); - self.backend_audio_instance_id = Some(instance_id); - - backend.clip_instance_to_backend_map.insert( - new_instance_id, - crate::action::BackendClipInstanceId::Audio(instance_id), - ); - - Ok(()) - } - ResolvedContent::Recording => { - // Recording clips cannot be split - Err("Cannot split a clip that is currently recording".to_string()) + None => {} } } + + // 2. Add the right (new) instance via the shared helper — same one AddClipInstanceAction + // uses, so the trim/duration conversions live in exactly one place. + if let Some((track_id, backend_id)) = + backend.add_clip_instance(document, &self.layer_id, &new_instance)? + { + self.backend_track_id = Some(track_id); + match backend_id { + crate::action::BackendClipInstanceId::Midi(id) => { + self.backend_midi_instance_id = Some(id) + } + crate::action::BackendClipInstanceId::Audio(id) => { + self.backend_audio_instance_id = Some(id) + } + } + } + + Ok(()) } fn rollback_backend( @@ -509,7 +449,9 @@ impl Action for SplitClipInstanceAction { if let Some(instance) = al.clip_instances.iter().find(|ci| ci.id == self.instance_id) { if let Some(clip) = document.get_audio_clip(&instance.clip_id) { let orig_internal_start = instance.trim_start; - let orig_internal_end = self.original_trim_end.unwrap_or(clip.content_duration().native()); + let orig_internal_end = self + .original_trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Restore based on clip type use crate::clip::ResolvedContent; @@ -564,8 +506,8 @@ mod tests { // Create a clip instance at timeline 0, with trim 0-10 (10 seconds) let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = daw_backend::Beats::ZERO; - clip_instance.trim_start = 0.0; - clip_instance.trim_end = Some(10.0); + clip_instance.trim_start = ContentTime::ZERO; + clip_instance.trim_end = Some(ContentTime(10.0)); let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -606,8 +548,8 @@ mod tests { let mut audio_layer = crate::layer::AudioLayer::new("Layer 1"); let mut instance = ClipInstance::new(clip_id); instance.timeline_start = daw_backend::Beats::ZERO; - instance.trim_start = 0.0; - instance.trim_end = Some(8.0); // beats + instance.trim_start = ContentTime::ZERO; + instance.trim_end = Some(ContentTime(8.0)); // beats let instance_id = instance.id; audio_layer.clip_instances.push(instance); let layer_id = document.root.add_child(AnyLayer::Audio(audio_layer)); @@ -620,7 +562,7 @@ mod tests { let right = al.clip_instances.iter().find(|ci| ci.id == new_id).unwrap(); let left = al.clip_instances.iter().find(|ci| ci.id == instance_id).unwrap(); - assert_eq!(right.trim_start, 4.0, "right half must start 4 BEATS into the content"); - assert_eq!(left.trim_end, Some(4.0), "left half must end 4 BEATS into the content"); + assert_eq!(right.trim_start, ContentTime(4.0), "right half must start 4 BEATS into the content"); + assert_eq!(left.trim_end, Some(ContentTime(4.0)), "left half must end 4 BEATS into the content"); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 3e3d1be..ef97fee 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -6,7 +6,7 @@ use crate::action::Action; use crate::clip::ClipInstance; use crate::document::Document; use crate::layer::AnyLayer; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use std::collections::HashMap; use uuid::Uuid; @@ -32,15 +32,57 @@ pub struct TrimClipInstancesAction { pub struct TrimData { /// For TrimLeft: trim_start value /// For TrimRight: trim_end value (Option because it can be None) - pub trim_value: Option, + /// + /// A content time — measured in the clip's own domain (seconds for audio/video/vector, beats + /// for MIDI), so it must be resolved against the clip before meeting a timeline position. + pub trim_value: Option, /// For TrimLeft: timeline_start value (where the clip appears on timeline, beats) /// For TrimRight: unused (None) pub timeline_start: Option, } +/// A wall-clock gap on the timeline, expressed in a clip's content domain. +/// +/// Trim validation clamps how far a clip may be dragged against the empty space next to it, and that +/// space is measured on the timeline (seconds) while the trim lives in the clip's content domain. For +/// wall-clock content they're the same number; for MIDI (beats content) the gap has to be converted +/// at the clip's position, or a seconds gap silently clamps a beats trim. +fn gap_to_content( + gap: Seconds, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> ContentTime { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => ContentTime(gap.seconds_to_f64()), + crate::clip::ClipDuration::Beats(_) => { + let beats = tempo_map + .seconds_to_beats(tempo_map.beats_to_seconds(timeline_start) + gap) + - timeline_start; + ContentTime(beats.beats_to_f64()) + } + } +} + +/// The inverse: a content-domain span as wall-clock seconds at the clip's position. +fn content_to_secs( + span: ContentTime, + clip_content: crate::clip::ClipDuration, + timeline_start: Beats, + tempo_map: &crate::tempo_map::TempoMap, +) -> Seconds { + match clip_content { + crate::clip::ClipDuration::Seconds(_) => Seconds(span.raw()), + crate::clip::ClipDuration::Beats(_) => { + tempo_map.beats_to_seconds(timeline_start + Beats(span.raw())) + - tempo_map.beats_to_seconds(timeline_start) + } + } +} + impl TrimData { /// Create TrimData for left trim - pub fn left(trim_start: f64, timeline_start: Beats) -> Self { + pub fn left(trim_start: ContentTime, timeline_start: Beats) -> Self { Self { trim_value: Some(trim_start), timeline_start: Some(timeline_start), @@ -48,7 +90,7 @@ impl TrimData { } /// Create TrimData for right trim - pub fn right(trim_end: Option) -> Self { + pub fn right(trim_end: Option) -> Self { Self { trim_value: trim_end, timeline_start: None, @@ -192,7 +234,8 @@ impl Action for TrimClipInstancesAction { .find(|ci| &ci.id == instance_id) .ok_or_else(|| format!("Instance {} not found", instance_id))?; - let clip_duration = document.get_clip_duration(&instance.clip_id) + // The clip's content duration in ITS OWN domain, so trims resolve correctly for MIDI. + let clip_content = document.clip_trim_duration(&instance.clip_id) .ok_or_else(|| format!("Clip {} not found", instance.clip_id))?; let mut clamped_new = new.clone(); @@ -204,23 +247,34 @@ impl Action for TrimClipInstancesAction { { // If extending to the left (new_trim < old_trim) if should_validate && new_trim < old_trim { - // Max leftward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_left( - layer_id, - instance_id, - instance.timeline_start, - ).seconds_to_f64(); - - // Calculate how much we want to extend (content seconds) - let desired_extend = old_trim - new_trim; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); - let clamped_trim_start = old_trim - actual_extend; - // Move the timeline left by the same wall-clock seconds. let tempo_map = document.tempo_map(); + + // Max leftward extension: the gap's wall-clock span, converted into + // the clip's content domain so it can clamp a content-domain trim. + let max_extend = gap_to_content( + document.find_max_trim_extend_left( + layer_id, + instance_id, + instance.timeline_start, + ), + clip_content, + instance.timeline_start, + tempo_map, + ); + + let desired_extend = old_trim - new_trim; + let actual_extend = desired_extend.min(max_extend); + let clamped_trim_start = old_trim - actual_extend; + + // Move the timeline left by the same span, as wall-clock seconds. + let shift = content_to_secs( + actual_extend, + clip_content, + instance.timeline_start, + tempo_map, + ); let clamped_timeline_start = tempo_map - .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - Seconds(actual_extend)) + .seconds_to_beats(tempo_map.beats_to_seconds(old_timeline) - shift) .max(Beats::ZERO); clamped_new = TrimData::left(clamped_trim_start, clamped_timeline_start); @@ -228,36 +282,39 @@ impl Action for TrimClipInstancesAction { } } TrimType::TrimRight => { - let old_trim_end = old.trim_value.unwrap_or(clip_duration.seconds_to_f64()); - let new_trim_end = new.trim_value.unwrap_or(clip_duration.seconds_to_f64()); + let content_end = ContentTime(clip_content.native()); + let old_trim_end = old.trim_value.unwrap_or(content_end); + let new_trim_end = new.trim_value.unwrap_or(content_end); // If extending to the right (new_trim_end > old_trim_end) if should_validate && new_trim_end > old_trim_end { let tempo_map = document.tempo_map(); - // Current effective duration in beats (content seconds - // converted to beats at the clip's start). - let content_secs = Seconds(old_trim_end - instance.trim_start); - let current_effective_duration = tempo_map.seconds_to_beats( - tempo_map.beats_to_seconds(instance.timeline_start) + content_secs, - ) - instance.timeline_start; - // Max rightward extension as content seconds (the gap's wall-clock span). - let max_extend_secs = document.find_max_trim_extend_right( - layer_id, - instance_id, + // How long the clip currently occupies the timeline, in beats. Resolved + // in the clip's own domain, so a MIDI clip's beats content isn't run + // through the seconds→beats conversion a second time. + let current_effective_duration = instance + .effective_duration_beats(clip_content, tempo_map); + + // Max rightward extension: the gap's wall-clock span, in content domain. + let max_extend = gap_to_content( + document.find_max_trim_extend_right( + layer_id, + instance_id, + instance.timeline_start, + current_effective_duration, + ), + clip_content, instance.timeline_start, - current_effective_duration, - ).seconds_to_f64(); + tempo_map, + ); - // Calculate how much we want to extend (content seconds) let desired_extend = new_trim_end - old_trim_end; - - // Clamp to max allowed - let actual_extend = desired_extend.min(max_extend_secs); + let actual_extend = desired_extend.min(max_extend); let clamped_trim_end = old_trim_end + actual_extend; - // Don't exceed clip duration - let final_trim_end = clamped_trim_end.min(clip_duration.seconds_to_f64()); + // Don't exceed the clip's content. + let final_trim_end = clamped_trim_end.min(content_end); clamped_new = TrimData::right(Some(final_trim_end)); } @@ -387,8 +444,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has new values after execute() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -424,7 +482,9 @@ impl Action for TrimClipInstancesAction { // Calculate new internal_start and internal_end for backend // Note: instance already has the new trim values after execute() let internal_start = instance.trim_start; - let internal_end = instance.trim_end.unwrap_or(clip.content_duration().native()); + let internal_end = instance + .trim_end + .unwrap_or(ContentTime(clip.content_duration().native())); // Handle trim based on clip type match &clip.resolve(instance.active_take) { @@ -477,8 +537,9 @@ impl Action for TrimClipInstancesAction { if let Some(&metatrack_id) = backend.layer_to_track_map.get(&instance.clip_id) { // Instance already has old values after rollback() controller.set_offset(metatrack_id, document.tempo_map().beats_to_seconds(instance.timeline_start)); - controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start)); - controller.set_trim_end(metatrack_id, instance.trim_end.map(daw_backend::Seconds)); + // A vector clip's content is wall-clock, so its content times ARE seconds. + controller.set_trim_start(metatrack_id, daw_backend::Seconds(instance.trim_start.raw())); + controller.set_trim_end(metatrack_id, instance.trim_end.map(|t| daw_backend::Seconds(t.raw()))); } } } @@ -512,13 +573,14 @@ impl Action for TrimClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Calculate old internal_start and internal_end for backend + let content_end = ContentTime(clip.content_duration().native()); let internal_start = match trim_type { - TrimType::TrimLeft => old.trim_value.unwrap_or(0.0), + TrimType::TrimLeft => old.trim_value.unwrap_or(ContentTime::ZERO), TrimType::TrimRight => instance.trim_start, // trim_start wasn't changed }; let internal_end = match trim_type { - TrimType::TrimLeft => instance.trim_end.unwrap_or(clip.content_duration().native()), // trim_end wasn't changed - TrimType::TrimRight => old.trim_value.unwrap_or(clip.content_duration().native()), + TrimType::TrimLeft => instance.trim_end.unwrap_or(content_end), // trim_end wasn't changed + TrimType::TrimRight => old.trim_value.unwrap_or(content_end), }; // Handle trim based on clip type @@ -569,7 +631,7 @@ mod tests { let mut clip_instance = ClipInstance::new(clip_id); clip_instance.timeline_start = Beats::ZERO; - clip_instance.trim_start = 0.0; + clip_instance.trim_start = ContentTime::ZERO; let instance_id = clip_instance.id; vector_layer.clip_instances.push(clip_instance); @@ -582,8 +644,8 @@ mod tests { vec![( instance_id, TrimType::TrimLeft, - TrimData::left(0.0, Beats::ZERO), - TrimData::left(2.0, Beats(2.0)), + TrimData::left(ContentTime::ZERO, Beats::ZERO), + TrimData::left(ContentTime(2.0), Beats(2.0)), )], ); @@ -599,7 +661,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.timeline_start, Beats(2.0)); } @@ -613,7 +675,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.timeline_start, Beats::ZERO); } } @@ -644,7 +706,7 @@ mod tests { instance_id, TrimType::TrimRight, TrimData::right(None), - TrimData::right(Some(8.0)), + TrimData::right(Some(ContentTime(8.0))), )], ); @@ -660,7 +722,7 @@ mod tests { .iter() .find(|ci| ci.id == instance_id) .unwrap(); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); } // Rollback diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 45459e3..447fbfe 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -14,7 +14,7 @@ use crate::layer::AnyLayer; use crate::layer_tree::LayerTree; use crate::object::Transform; -use daw_backend::{Beats, Seconds}; +use daw_backend::{Beats, ContentTime, Seconds}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use uuid::Uuid; @@ -130,10 +130,14 @@ impl VectorClip { let end_beats: Beats = if let Some(td_beats) = ci.timeline_duration { ci.timeline_start + td_beats } else if let Some(te) = ci.trim_end { - let secs = (te - ci.trim_start).max(0.0); + // `clip_duration_fn` hands back seconds, so this whole path treats nested + // content as wall-clock. That's right for the vector/video/audio clips a vector + // clip actually nests; a nested MIDI clip (beats content) would need resolving + // against its clip, which this callback can't do. Pre-existing limitation. + let secs = (te - ci.trim_start).raw().max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else if let Some(clip_dur_secs) = clip_duration_fn(&ci.clip_id) { - let secs = (clip_dur_secs - ci.trim_start).max(0.0); + let secs = (clip_dur_secs - ci.trim_start.raw()).max(0.0); tempo_map.seconds_to_beats(tempo_map.beats_to_seconds(ci.timeline_start) + Seconds(secs)) } else { continue; @@ -201,7 +205,9 @@ impl VectorClip { // Convert parent clip time (seconds) to nested clip local time (seconds). // timeline_start is in beats; convert to seconds using document BPM. let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let nested_clip_time = ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + // Nested clips here are vector clips, whose content is wall-clock seconds. + let nested_clip_time = + ((clip_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Look up the nested clip definition let nested_bounds = if let Some(nested_clip) = document.get_vector_clip(&clip_instance.clip_id) { @@ -535,6 +541,17 @@ impl ClipDuration { ClipDuration::Beats(b) => b.beats_to_f64(), } } + + /// Tag a [`ContentTime`] with *this* duration's domain. + /// + /// Handy when you already hold a clip's content duration (so you know the domain) and need to + /// resolve one of its trim bounds, without going back to the clip. + pub fn same_domain(self, t: ContentTime) -> ClipDuration { + match self { + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(t.raw())), + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(t.raw())), + } + } } /// Audio clip @@ -739,21 +756,33 @@ impl AudioClip { } } - /// Tag a pair of raw trim bounds with this clip's content domain, ready for the backend. + /// Resolve a content time against this clip's domain. /// - /// `ClipInstance::trim_start`/`trim_end` are bare `f64`s whose unit depends on the clip — - /// SECONDS for sampled audio, BEATS for MIDI. Building the [`TrimRange`] from the clip means a - /// caller can't reach for the wrong variant: the clip is the one thing that knows. - pub fn trim_range(&self, start: f64, end: f64) -> daw_backend::command::TrimRange { + /// This is the ONLY sanctioned way to turn a [`ContentTime`] into a real duration — the type has + /// no `.to_seconds()` of its own precisely so that the clip, which is the one thing that knows + /// whether its content is measured in seconds or beats, has to be consulted. + pub fn resolve_content_time(&self, t: ContentTime) -> ClipDuration { + if self.is_midi_domain() { + ClipDuration::Beats(Beats(t.raw())) + } else { + ClipDuration::Seconds(Seconds(t.raw())) + } + } + + /// Tag a pair of trim bounds with this clip's content domain, ready for the backend. + /// + /// Building the [`TrimRange`] from the clip means a caller can't reach for the wrong variant: + /// the clip is the one thing that knows the domain. + pub fn trim_range(&self, start: ContentTime, end: ContentTime) -> daw_backend::command::TrimRange { if self.is_midi_domain() { daw_backend::command::TrimRange::Beats { - start: Beats(start), - end: Beats(end), + start: Beats(start.raw()), + end: Beats(end.raw()), } } else { daw_backend::command::TrimRange::Seconds { - start: Seconds(start), - end: Seconds(end), + start: Seconds(start.raw()), + end: Seconds(end.raw()), } } } @@ -885,16 +914,17 @@ pub struct ClipInstance { /// Default: None (use trimmed clip duration, no looping) pub timeline_duration: Option, - /// Trim start: offset into the clip's internal content, in **seconds**. - /// - For audio: byte-offset into the audio file - /// - For video: seek position in the video file - /// - For vector: time offset into the animation + /// Trim start: offset into the clip's internal content. + /// + /// A [`ContentTime`] — measured in the CLIP's content domain, which is seconds for sampled + /// audio/video/vector but BEATS for MIDI. Resolve it against the clip + /// ([`Document::resolve_content_time`]) before combining it with anything on the timeline. /// Default: 0.0 - pub trim_start: f64, + pub trim_start: ContentTime, - /// Trim end: offset into the clip's internal content, in **seconds**. + /// Trim end: offset into the clip's internal content. See [`Self::trim_start`]. /// Default: None (use full clip duration) - pub trim_end: Option, + pub trim_end: Option, /// Playback speed multiplier /// 1.0 = normal speed, 0.5 = half speed, 2.0 = double speed @@ -973,7 +1003,7 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, @@ -992,7 +1022,7 @@ impl ClipInstance { name: None, timeline_start: Beats::ZERO, timeline_duration: None, - trim_start: 0.0, + trim_start: ContentTime::ZERO, trim_end: None, playback_speed: 1.0, gain: 1.0, @@ -1033,7 +1063,7 @@ impl ClipInstance { } /// Set trimming (start and end time within the clip's internal content) - pub fn with_trimming(mut self, trim_start: f64, trim_end: Option) -> Self { + pub fn with_trimming(mut self, trim_start: ContentTime, trim_end: Option) -> Self { self.trim_start = trim_start; self.trim_end = trim_end; self @@ -1057,24 +1087,40 @@ impl ClipInstance { self } - /// Content window size in seconds: `trim_end - trim_start`. + /// Content window (`trim_end - trim_start`) in the clip's own content domain. /// Used for internal looping calculations. - pub fn content_window_secs(&self, clip_duration_secs: Seconds) -> Seconds { - let end = self.trim_end.unwrap_or(clip_duration_secs.seconds_to_f64()); - Seconds((end - self.trim_start).max(0.0)) + pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { + let end = self.trim_end.map_or(clip_content.native(), |t| t.raw()); + let window = (end - self.trim_start.raw()).max(0.0); + match clip_content { + ClipDuration::Beats(_) => ClipDuration::Beats(Beats(window)), + ClipDuration::Seconds(_) => ClipDuration::Seconds(Seconds(window)), + } } /// How long this instance appears on the timeline, in **beats**. /// - /// If `timeline_duration` is set, returns that (enabling content looping). - /// Otherwise converts the content window from seconds to beats using the tempo map. - pub fn effective_duration_beats(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + /// If `timeline_duration` is set, returns that (enabling content looping). Otherwise the clip + /// occupies its content window — converted to beats *in the clip's own domain*: + /// + /// - MIDI content is already beats and is tempo-invariant, so it carries over directly. + /// - Wall-clock content (audio/video/vector) is a seconds span, so it converts at the clip's + /// position on the timeline. + /// + /// Taking a `ClipDuration` rather than a bare `Seconds` is what keeps those apart: this used to + /// take seconds and subtract `trim_start` from it, which for a TRIMMED MIDI clip subtracted a + /// beats offset from a seconds duration and got the clip's length wrong. + pub fn effective_duration_beats(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { if let Some(td) = self.timeline_duration { return td; } - let window = self.content_window_secs(clip_duration_secs); - let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - tempo_map.seconds_to_beats(start_secs + window) - self.timeline_start + match self.content_window(clip_content) { + ClipDuration::Beats(b) => b, + ClipDuration::Seconds(s) => { + let start_secs = tempo_map.beats_to_seconds(self.timeline_start); + tempo_map.seconds_to_beats(start_secs + s) - self.timeline_start + } + } } /// Left edge of the clip's visual extent on the timeline, in **beats**. @@ -1083,27 +1129,32 @@ impl ClipInstance { } /// Total visual duration (loop_before + effective_duration), in **beats**. - pub fn total_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn total_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.loop_before.unwrap_or(Beats::ZERO) + self.effective_duration_beats(clip_content, tempo_map) } /// Map a playback time (in **seconds**) to clip-local content time (in **seconds**). /// - /// Returns `None` if the clip instance is not active at `time_secs`. - pub fn remap_time_secs(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { + /// The trim bounds are resolved through `clip_content`'s domain first, so a MIDI clip's beats + /// trims are converted rather than read as seconds. Callers are the wall-clock consumers (video + /// seek, vector/raster rendering), which want seconds regardless of how the clip stores content. + /// + /// Returns `None` if the clip instance is not active at `time`. + pub fn remap_time_secs(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { let start_secs = tempo_map.beats_to_seconds(self.timeline_start); - let dur_beats = self.effective_duration_beats(clip_duration_secs, tempo_map); + let dur_beats = self.effective_duration_beats(clip_content, tempo_map); let end_secs = tempo_map.beats_to_seconds(self.timeline_start + dur_beats); if time < start_secs || time >= end_secs { return None; } + let trim_start_secs = clip_content.same_domain(self.trim_start).to_seconds(tempo_map); let content_time = (time - start_secs) * self.playback_speed; - let content_window = self.content_window_secs(clip_duration_secs); + let content_window = self.content_window(clip_content).to_seconds(tempo_map); if content_window == Seconds::ZERO { - return Some(Seconds(self.trim_start)); + return Some(trim_start_secs); } let looped = if content_time > content_window { @@ -1112,19 +1163,19 @@ impl ClipInstance { content_time }; - Some(Seconds(self.trim_start) + looped) + Some(trim_start_secs + looped) } /// Alias for `remap_time_secs`. #[inline] - pub fn remap_time(&self, time: Seconds, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Option { - self.remap_time_secs(time, clip_duration_secs, tempo_map) + pub fn remap_time(&self, time: Seconds, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Option { + self.remap_time_secs(time, clip_content, tempo_map) } /// Alias for `effective_duration_beats`. #[inline] - pub fn effective_duration(&self, clip_duration_secs: Seconds, tempo_map: &crate::tempo_map::TempoMap) -> Beats { - self.effective_duration_beats(clip_duration_secs, tempo_map) + pub fn effective_duration(&self, clip_content: ClipDuration, tempo_map: &crate::tempo_map::TempoMap) -> Beats { + self.effective_duration_beats(clip_content, tempo_map) } /// Convert to affine transform @@ -1201,7 +1252,7 @@ mod tests { assert_eq!(instance.clip_id, clip_id); assert_eq!(instance.opacity, 1.0); assert_eq!(instance.timeline_start, Beats::ZERO); - assert_eq!(instance.trim_start, 0.0); + assert_eq!(instance.trim_start, ContentTime::ZERO); assert_eq!(instance.trim_end, None); assert_eq!(instance.playback_speed, 1.0); assert_eq!(instance.gain, 1.0); @@ -1211,27 +1262,52 @@ mod tests { fn test_clip_instance_trimming() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, Some(8.0)); + .with_trimming(ContentTime(2.0), Some(ContentTime(8.0))); - assert_eq!(instance.trim_start, 2.0); - assert_eq!(instance.trim_end, Some(8.0)); + assert_eq!(instance.trim_start, ContentTime(2.0)); + assert_eq!(instance.trim_end, Some(ContentTime(8.0))); // At 60 BPM the tempo map is identity (1 beat == 1 second), so the // beats-domain effective duration equals the seconds content window. let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(6.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(6.0)); } #[test] fn test_clip_instance_no_end_trim() { let clip_id = Uuid::new_v4(); let instance = ClipInstance::new(clip_id) - .with_trimming(2.0, None); + .with_trimming(ContentTime(2.0), None); - assert_eq!(instance.trim_start, 2.0); + assert_eq!(instance.trim_start, ContentTime(2.0)); assert_eq!(instance.trim_end, None); // At 60 BPM the tempo map is identity (1 beat == 1 second). let tempo_map = crate::tempo_map::TempoMap::constant(60.0); - assert_eq!(instance.effective_duration(Seconds(10.0), &tempo_map), Beats(8.0)); + let content = ClipDuration::Seconds(Seconds(10.0)); + assert_eq!(instance.effective_duration(content, &tempo_map), Beats(8.0)); + } + + #[test] + fn trimmed_midi_clip_keeps_its_beats_length_across_tempo() { + // Regression: `effective_duration_beats` used to take a SECONDS clip duration and subtract + // `trim_start` from it. For a TRIMMED MIDI clip that subtracted a beats offset from a + // seconds duration, so the clip's timeline length came out wrong at any tempo but 60 BPM. + // + // MIDI content is beats and tempo-invariant: a clip trimmed to beats 2..6 is 4 beats long + // whatever the tempo says. + let clip_id = Uuid::new_v4(); + let instance = ClipInstance::new(clip_id) + .with_trimming(ContentTime(2.0), Some(ContentTime(6.0))); + let content = ClipDuration::Beats(Beats(8.0)); + + for bpm in [60.0, 120.0, 90.0] { + let tempo_map = crate::tempo_map::TempoMap::constant(bpm); + assert_eq!( + instance.effective_duration(content, &tempo_map), + Beats(4.0), + "a MIDI clip trimmed to beats 2..6 is 4 beats long at {bpm} BPM", + ); + } } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 0029a75..a5ec6b6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -464,8 +464,11 @@ impl Document { let end_beats: Beats = if let Some(timeline_duration) = instance.timeline_duration { instance.timeline_start + timeline_duration } else { - let trim_end = instance.trim_end.unwrap_or(clip_duration); - let trimmed_secs = ((trim_end - instance.trim_start) / instance.playback_speed).max(0.0); + // `clip_duration` arrives as seconds (the recursive helper's signature), so this + // path is the wall-clock one; MIDI content would need resolving against its clip. + let trim_end = instance.trim_end.map_or(clip_duration, |t| t.raw()); + let trimmed_secs = + ((trim_end - instance.trim_start.raw()) / instance.playback_speed).max(0.0); let start_secs = tempo_map.beats_to_seconds(instance.timeline_start); tempo_map.seconds_to_beats(start_secs + Seconds(trimmed_secs)) }; @@ -915,11 +918,10 @@ impl Document { /// have infinite internal duration. /// A clip's content duration **in the domain its `trim_start`/`trim_end` are measured in**. /// - /// `ClipInstance::trim_*` is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for - /// sampled audio, video and vector, but BEATS for MIDI (the backend takes MIDI trims as - /// `Beats`). Anything doing arithmetic against a trim value — mapping a timeline position into - /// the clip's content, say — has to work in that same domain, and [`Self::get_clip_duration`] - /// can't tell it which: that one always converts to seconds. + /// Content time is domain-polymorphic exactly like `AudioClip::duration`: SECONDS for sampled + /// audio, video and vector, but BEATS for MIDI. Anything doing arithmetic against a trim value — + /// mapping a timeline position into the clip's content, say — has to work in that same domain, + /// and [`Self::get_clip_duration`] can't tell it which: that one always converts to seconds. /// /// Returns `None` for unknown clips. pub fn clip_trim_duration(&self, clip_id: &Uuid) -> Option { @@ -930,6 +932,29 @@ impl Document { self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) } + /// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to. + /// + /// The clip is the only thing that knows whether its content is measured in seconds or beats, so + /// this is the sanctioned exit from `ContentTime`. Works for every clip kind, not just audio. + /// Returns `None` for unknown clips. + pub fn resolve_content_time( + &self, + clip_id: &Uuid, + t: daw_backend::ContentTime, + ) -> Option { + if let Some(clip) = self.audio_clips.get(clip_id) { + return Some(clip.resolve_content_time(t)); + } + if self.vector_clips.contains_key(clip_id) + || self.video_clips.contains_key(clip_id) + || self.effect_definitions.contains_key(clip_id) + { + // Wall-clock content. + return Some(crate::clip::ClipDuration::Seconds(Seconds(t.raw()))); + } + None + } + pub fn get_clip_duration(&self, clip_id: &Uuid) -> Option { if let Some(clip) = self.vector_clips.get(clip_id) { if clip.is_group { @@ -983,9 +1008,9 @@ impl Document { }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; - let clip_duration = self.get_clip_duration(&instance.clip_id)?; - // End position on the timeline, in beats (convert the seconds content window via tempo map). - Some(instance.timeline_start + instance.effective_duration_beats(clip_duration, self.tempo_map())) + // The clip's content duration in ITS OWN domain, so the trims resolve correctly for MIDI. + let clip_content = self.clip_trim_duration(&instance.clip_id)?; + Some(instance.timeline_start + instance.effective_duration_beats(clip_content, self.tempo_map())) } /// Check if a time range overlaps with any existing clip on the layer @@ -1025,13 +1050,14 @@ impl Document { continue; } - // Calculate instance extent (accounting for loop_before) - let Some(clip_duration) = self.get_clip_duration(&instance.clip_id) else { + // Calculate instance extent (accounting for loop_before). Content duration in the clip's + // own domain, so the trims resolve correctly for MIDI. + let Some(clip_content) = self.clip_trim_duration(&instance.clip_id) else { continue; }; let instance_start = instance.effective_start(); - let instance_end = instance.timeline_start + instance.effective_duration(clip_duration, self.tempo_map()); + let instance_end = instance.timeline_start + instance.effective_duration(clip_content, self.tempo_map()); // Check overlap: start_a < end_b AND start_b < end_a if start_time < instance_end && instance_start < end_time { @@ -1088,7 +1114,7 @@ impl Document { continue; } - if let Some(clip_dur) = self.get_clip_duration(&instance.clip_id) { + if let Some(clip_dur) = self.clip_trim_duration(&instance.clip_id) { let inst_start = instance.effective_start(); let inst_end = instance.timeline_start + instance.effective_duration(clip_dur, self.tempo_map()); occupied_ranges.push((inst_start, inst_end, instance.id)); @@ -1184,7 +1210,7 @@ impl Document { if group_ids.contains(&inst.id) { continue; } - if let Some(dur) = self.get_clip_duration(&inst.clip_id) { + if let Some(dur) = self.clip_trim_duration(&inst.clip_id) { let start = inst.effective_start(); let end = inst.timeline_start + inst.effective_duration(dur, self.tempo_map()); non_group.push((start, end)); @@ -1258,9 +1284,8 @@ impl Document { } // Calculate other clip's extent (accounting for loop_before) - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); - // (clip_duration is Seconds via get_clip_duration; effective_duration converts.) // If this clip is to the left and closer than current nearest if other_end <= current_timeline_start && other_end > nearest_end { @@ -1358,7 +1383,7 @@ impl Document { continue; } - if let Some(clip_duration) = self.get_clip_duration(&other.clip_id) { + if let Some(clip_duration) = self.clip_trim_duration(&other.clip_id) { let other_end = other.timeline_start + other.effective_duration(clip_duration, self.tempo_map()); if other_end <= current_effective_start && other_end > nearest_end { diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index 3d52675..a6f762a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -152,7 +152,12 @@ impl EffectLayer { self.clip_instances .iter() .filter(|e| { - let end = e.timeline_start + e.effective_duration(daw_backend::Seconds(EFFECT_DURATION), tempo_map); + // Effects have an "infinite" wall-clock content length. + let end = e.timeline_start + + e.effective_duration( + crate::clip::ClipDuration::Seconds(daw_backend::Seconds(EFFECT_DURATION)), + tempo_map, + ); time_beats >= e.timeline_start && time_beats < end }) .collect() diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 9cc7a92..e9d9f39 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -3,7 +3,7 @@ //! Provides functions for testing if points or rectangles intersect with //! vector graph elements and clip instances, taking into account transform hierarchies. -use crate::clip::ClipInstance; +use crate::clip::{ClipDuration, ClipInstance}; use crate::vector_graph::{VertexId, EdgeId, FillId}; use crate::layer::VectorLayer; use crate::shape::Shape; @@ -260,7 +260,10 @@ pub fn hit_test_clip_instances( for clip_instance in clip_instances.iter().rev() { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -269,7 +272,8 @@ pub fn hit_test_clip_instances( // clip_time is in seconds; offset from clip start (in seconds) + trim_start (seconds) let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) @@ -304,7 +308,10 @@ pub fn hit_test_clip_instances_in_rect( for clip_instance in clip_instances { // Check time bounds: skip clip instances not active at this time // timeline_start/instance_end are in beats; convert timeline_time (seconds) to beats. - let clip_duration = document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO); + // Hit-testing runs on vector/raster content, which is wall-clock seconds. + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&clip_instance.clip_id).unwrap_or(daw_backend::Seconds::ZERO), + ); let instance_end = clip_instance.timeline_start + clip_instance.effective_duration(clip_duration, tempo_map); let timeline_beats = tempo_map.seconds_to_beats(daw_backend::Seconds(timeline_time)); if timeline_beats < clip_instance.timeline_start || timeline_beats >= instance_end { @@ -312,7 +319,8 @@ pub fn hit_test_clip_instances_in_rect( } let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = + ((timeline_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&clip_instance.clip_id) { vector_clip.calculate_content_bounds(document, clip_time) diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 9c2cbd8..72df6cd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -9,7 +9,7 @@ //! The compositing mode enables proper per-layer opacity, blend modes, and effects. use crate::animation::TransformProperty; -use crate::clip::{ClipInstance, ImageAsset}; +use crate::clip::{ClipDuration, ClipInstance, ImageAsset}; use crate::document::Document; use daw_backend::Seconds; use crate::gpu::BlendMode; @@ -568,7 +568,7 @@ pub fn render_layer_isolated( let tempo_map = document.tempo_map(); for clip_instance in &video_layer.clip_instances { let Some(video_clip) = document.video_clips.get(&clip_instance.clip_id) else { continue }; - let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { continue }; + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue }; let clip_time = clip_time.seconds_to_f64(); let Some(frame) = video_mgr.get_frame(&clip_instance.clip_id, clip_time, target_w, target_h) else { continue }; @@ -1019,7 +1019,10 @@ fn render_clip_instance( } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return; // Clip instance not active at this time }; @@ -1174,7 +1177,7 @@ fn render_video_layer( // Remap timeline time to clip's internal time let tempo_map = document.tempo_map(); - let Some(clip_time) = clip_instance.remap_time(Seconds(time), Seconds(video_clip.duration), tempo_map) else { + let Some(clip_time) = clip_instance.remap_time(Seconds(time), ClipDuration::Seconds(Seconds(video_clip.duration)), tempo_map) else { continue; // Clip instance not active at this time }; let clip_time = clip_time.seconds_to_f64(); @@ -1910,7 +1913,10 @@ fn render_clip_instance_cpu( if time < start_secs || time >= end { return; } 0.0 } else { - let clip_dur = document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)); + // A vector clip's content is wall-clock seconds. + let clip_dur = ClipDuration::Seconds( + document.get_clip_duration(&vector_clip.id).unwrap_or(Seconds(vector_clip.duration)), + ); let Some(t) = clip_instance.remap_time(Seconds(time), clip_dur, tempo_map) else { return }; t.seconds_to_f64() }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index bea0759..cde1545 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -1064,7 +1064,12 @@ fn composite_document_to_hdr( } let tempo_map = document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(daw_backend::Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + lightningbeam_core::clip::ClipDuration::Seconds(daw_backend::Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + )), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 6a35973..33adcfd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2387,7 +2387,7 @@ impl EditorApp { ) -> Vec { let mut result = Vec::new(); for instance in clip_instances { - if let Some(clip_duration) = document.get_clip_duration(&instance.clip_id) { + if let Some(clip_duration) = document.clip_trim_duration(&instance.clip_id) { let effective_duration = instance.effective_duration(clip_duration, document.tempo_map()); let timeline_end = instance.timeline_start + effective_duration; @@ -3276,7 +3276,9 @@ impl EditorApp { let duplicates: Vec = clips_to_duplicate.iter().map(|original| { let mut duplicate = original.clone(); duplicate.id = uuid::Uuid::new_v4(); - let clip_duration = document.get_clip_duration(&original.clip_id).unwrap_or(Seconds(1.0)); + let clip_duration = document + .clip_trim_duration(&original.clip_id) + .unwrap_or(ClipDuration::Seconds(Seconds(1.0))); let effective_duration = original.effective_duration(clip_duration, document.tempo_map()); duplicate.timeline_start = original.timeline_start + effective_duration; if let Some((new_clip_def_id, _)) = midi_clip_replacements.get(&original.clip_id) { @@ -5477,7 +5479,7 @@ impl EditorApp { // matches the video clip exactly). let (_dur, sample_rate, channels) = controller .get_pool_file_info(pool_index) - .unwrap_or((video_duration, 0, 0)); + .unwrap_or((Seconds(video_duration), 0, 0)); drop(controller); let audio_clip_name = format!("{} (Audio)", video_name); @@ -6384,7 +6386,9 @@ impl eframe::App for EditorApp { use daw_backend::AudioEvent; match event { AudioEvent::PlaybackPosition(time) => { - self.playback_time = time; + // `playback_time` is the UI's seconds playhead (see the timeline's + // seconds/beats model); unwrap at this boundary, not before it. + self.playback_time = time.seconds_to_f64(); } AudioEvent::PlaybackStopped => { self.is_playing = false; @@ -6595,8 +6599,11 @@ impl eframe::App for EditorApp { if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.id == instance_id) { inst.timeline_start = loop_start; inst.timeline_duration = None; - inst.trim_start = 0.0; - inst.trim_end = Some(loop_len_seconds.seconds_to_f64()); + // Audio take content is seconds, and each take spans + // exactly one cycle region. + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = + Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); inst.active_take = Some(last_take); } } @@ -6655,6 +6662,7 @@ impl eframe::App for EditorApp { let mut controller = controller_arc.lock().unwrap(); match controller.get_pool_file_info(pool_index) { Ok((dur, _, _)) => { + let dur = dur.seconds_to_f64(); eprintln!("[AUDIO] Got duration from backend: {:.4}s", dur); self.audio_duration_cache.insert(pool_index, dur); dur @@ -6688,7 +6696,7 @@ impl eframe::App for EditorApp { None } }) - .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, 0.0)) + .unwrap_or((uuid::Uuid::nil(), uuid::Uuid::nil(), Beats::ZERO, daw_backend::ContentTime::ZERO)) }; if !clip_id.is_nil() { @@ -6850,7 +6858,7 @@ impl eframe::App for EditorApp { .map(|(id, _)| id); if let Some(doc_clip_id) = doc_clip_id { if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { - clip.set_content_duration(ClipDuration::Beats(Beats(midi_clip_data.duration))); + clip.set_content_duration(ClipDuration::Beats(midi_clip_data.duration)); clip.name = format!("MIDI Recording {}", clip_id); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 74f0d78..0df4edf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1612,8 +1612,12 @@ impl InfopanelPane { ui.label(format!("{:.2}s", document.tempo_map().beats_to_seconds(ci.effective_start()).seconds_to_f64())); }); - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| daw_backend::Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| lightningbeam_core::clip::ClipDuration::Seconds( + daw_backend::Seconds( + (ci.trim_end.unwrap_or(daw_backend::ContentTime(1.0)) - ci.trim_start).raw(), + ), + )); let total_dur = ci.total_duration(clip_dur, document.tempo_map()); let total_dur_secs = (document.tempo_map().beats_to_seconds(ci.effective_start() + total_dur) - document.tempo_map().beats_to_seconds(ci.effective_start())).seconds_to_f64(); @@ -1622,10 +1626,16 @@ impl InfopanelPane { ui.label(format!("{:.2}s", total_dur_secs)); }); - if ci.trim_start > 0.0 { + if ci.trim_start > daw_backend::ContentTime::ZERO { ui.horizontal(|ui| { ui.label("Trim Start:"); - ui.label(format!("{:.2}s", ci.trim_start)); + // Content time in the clip's own domain: seconds for sampled + // audio/video/vector, beats for MIDI. Label it accordingly. + let unit = match clip_dur { + lightningbeam_core::clip::ClipDuration::Beats(_) => "beats", + lightningbeam_core::clip::ClipDuration::Seconds(_) => "s", + }; + ui.label(format!("{:.2}{}", ci.trim_start.raw(), unit)); }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 2505ef3..fa3cd2e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -466,8 +466,10 @@ impl PianoRollPane { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { - let duration = instance.effective_duration(clip.content_duration().to_seconds(document.tempo_map()), document.tempo_map()); - clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), instance.id)); + let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); + // A MIDI clip's content time IS beats, which is what the piano roll's + // x-axis uses. + clip_data.push((midi_clip_id, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), instance.id)); } } } @@ -2463,7 +2465,8 @@ impl PianoRollPane { }); // Get sample rate from raw_audio_cache if let Some((_samples, sr, _ch)) = shared.raw_audio_cache.get(&audio_pool_index) { - clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start, duration.beats_to_f64(), *sr)); + // A sampled clip's content time is seconds. + clip_infos.push((audio_pool_index, instance.timeline_start.beats_to_f64(), instance.trim_start.raw(), duration.beats_to_f64(), *sr)); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 73dd94d..2b0854a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,7 +6,7 @@ use eframe::egui; use daw_backend::Seconds; use lightningbeam_core::action::Action; -use lightningbeam_core::clip::ClipInstance; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::gpu::{BufferPool, BufferFormat, BufferSpec, Compositor, EffectProcessor, SrgbToLinearConverter}; use lightningbeam_core::layer::{AnyLayer, AudioLayer}; use lightningbeam_core::renderer::RenderedLayerType; @@ -1854,7 +1854,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // For now, create a simple effect instance with default parameters let tempo_map = self.ctx.document.tempo_map(); let effect_end_beats = effect_instance.timeline_start - + effect_instance.effective_duration(Seconds(lightningbeam_core::effect::EFFECT_DURATION), tempo_map); + + effect_instance.effective_duration( + ClipDuration::Seconds(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + tempo_map, + ); let effect_inst = lightningbeam_core::effect::EffectInstance::new( effect_def, tempo_map.beats_to_seconds(effect_instance.timeline_start).seconds_to_f64(), @@ -2209,7 +2212,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Calculate clip bounds for preview let start_secs = self.ctx.document.tempo_map().beats_to_seconds(clip_inst.timeline_start).seconds_to_f64(); - let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_inst.playback_speed) + clip_inst.trim_start.raw(); let content_bounds = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_inst.clip_id) { vector_clip.calculate_content_bounds(&self.ctx.document, clip_time) } else if let Some(video_clip) = self.ctx.document.get_video_clip(&clip_inst.clip_id) { @@ -2299,7 +2302,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { for &clip_id in self.ctx.selection.clip_instances() { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Skip clip instances not active at current time (compare in seconds). - let clip_dur = self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO); + let clip_dur = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&clip_instance.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let instance_end = tempo_map.beats_to_seconds( @@ -2310,7 +2315,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } // Calculate clip-local time - let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((self.ctx.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let bbox = if let Some(vector_clip) = self.ctx.document.get_vector_clip(&clip_instance.clip_id) { @@ -2680,7 +2685,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Find clip instance visible at playback time let visible_clip = video_layer.clip_instances.iter().find(|inst| { - let clip_duration = self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + self.ctx.document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = self.ctx.document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); @@ -10142,7 +10149,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == clip_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time use vello::kurbo::Rect as KurboRect; @@ -10343,7 +10350,7 @@ impl StagePane { if let Some(clip_instance) = vector_layer.clip_instances.iter().find(|ci| ci.id == object_id) { // Calculate clip-local time let start_secs = shared.action_executor.document().tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); - let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start; + let clip_time = ((*shared.playback_time - start_secs) * clip_instance.playback_speed) + clip_instance.trim_start.raw(); // Get dynamic clip bounds from content at current time let local_bbox = if let Some(vector_clip) = shared.action_executor.document().get_vector_clip(&clip_instance.clip_id) { @@ -11059,7 +11066,9 @@ impl StagePane { let document = shared.action_executor.document(); if let Some(AnyLayer::Video(video_layer)) = document.get_layer(layer_id) { video_layer.clip_instances.iter().find(|inst| { - let clip_duration = document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO); + let clip_duration = ClipDuration::Seconds( + document.get_clip_duration(&inst.clip_id).unwrap_or(Seconds::ZERO), + ); let tempo_map = document.tempo_map(); let start_secs = tempo_map.beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end_secs = tempo_map.beats_to_seconds(inst.timeline_start + inst.effective_duration(clip_duration, tempo_map)).seconds_to_f64(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 03eb16d..798180c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -7,8 +7,8 @@ /// - Basic layer visualization use eframe::egui; -use daw_backend::{Beats, Seconds}; -use lightningbeam_core::clip::ClipInstance; +use daw_backend::{Beats, ContentTime, Seconds}; +use lightningbeam_core::clip::{ClipDuration, ClipInstance}; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; @@ -89,7 +89,7 @@ fn compute_clip_stacking( let tempo_map = document.tempo_map(); // Stacking only needs relative overlap, so compare in the beats domain. let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| { - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); (start.beats_to_f64(), end.beats_to_f64()) @@ -203,11 +203,14 @@ fn draw_video_thumbnail_strip( /// Get the effective clip duration for a clip instance on a given layer. /// For groups on vector layers, the duration spans all consecutive keyframes /// where the group is present. For regular clips, returns the clip's internal duration. +/// A clip's content duration **in its own domain** — seconds for vector/video/effect and sampled +/// audio, BEATS for MIDI. Returning a `ClipDuration` rather than bare `Seconds` is what lets the +/// instance's trim bounds (which are content times, in that same domain) be resolved correctly. fn effective_clip_duration( document: &lightningbeam_core::document::Document, layer: &AnyLayer, clip_instance: &ClipInstance, -) -> Option { +) -> Option { match layer { AnyLayer::Vector(vl) => { let vc = document.get_vector_clip(&clip_instance.clip_id)?; @@ -215,17 +218,21 @@ fn effective_clip_duration( let frame_duration = 1.0 / document.framerate; let start_secs = document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64(); let end = vl.group_visibility_end(&clip_instance.id, start_secs, frame_duration); - Some(Seconds((end - start_secs).max(0.0))) + Some(ClipDuration::Seconds(Seconds((end - start_secs).max(0.0)))) } else { // Movie clips: duration based on all internal content (keyframes + clip instances) - document.get_clip_duration(&clip_instance.clip_id) + document.get_clip_duration(&clip_instance.clip_id).map(ClipDuration::Seconds) } } - // Delegate to get_clip_duration so MIDI clips (whose `duration` is stored in beats, - // not seconds) are converted correctly rather than read as raw seconds. - AnyLayer::Audio(_) => document.get_clip_duration(&clip_instance.clip_id), - AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| Seconds(c.duration)), - AnyLayer::Effect(_) => Some(Seconds(lightningbeam_core::effect::EFFECT_DURATION)), + // An audio layer can hold a sampled clip (seconds content) or a MIDI clip (beats content), + // so ask the clip which it is rather than flattening both to seconds. + AnyLayer::Audio(_) => document.clip_trim_duration(&clip_instance.clip_id), + AnyLayer::Video(_) => document + .get_video_clip(&clip_instance.clip_id) + .map(|c| ClipDuration::Seconds(Seconds(c.duration))), + AnyLayer::Effect(_) => Some(ClipDuration::Seconds(Seconds( + lightningbeam_core::effect::EFFECT_DURATION, + ))), AnyLayer::Group(_) => None, AnyLayer::Raster(_) => None, AnyLayer::Text(_) => None, @@ -859,7 +866,9 @@ impl TimelinePane { .unwrap_or_default() .iter() .map(|k| crate::curve_editor::CurvePoint { - time: k.time, // beats (backend stores beats; curve editor x-axis is beats) + // The curve editor's x-axis is beats, same as the backend — unwrap at this + // boundary because CurvePoint stores a plain f64. + time: k.time.beats_to_f64(), value: k.value, interpolation: match k.interpolation.as_str() { "bezier" => crate::curve_editor::CurveInterpolation::Bezier, @@ -1441,8 +1450,10 @@ impl TimelinePane { let tempo_map = document.tempo_map(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let start = ci.effective_start(); let end = start + ci.total_duration(clip_dur, tempo_map); @@ -1756,15 +1767,18 @@ impl TimelinePane { /// Effective on-timeline duration for a clip instance, in seconds. /// /// `total_duration` is in beats; converts to seconds using the current (preview) BPM. - fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, tempo_map: &daw_backend::TempoMap) -> f64 { - (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) + fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, tempo_map: &daw_backend::TempoMap) -> f64 { + (tempo_map.beats_to_seconds(ci.timeline_start + ci.total_duration(clip_content, tempo_map)) - tempo_map.beats_to_seconds(ci.effective_start())).seconds_to_f64() } - /// Returns the clip content start (trim_start) and duration in display seconds. - fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: Seconds, _bpm: f64) -> (f64, f64) { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs.seconds_to_f64()); - (ci.trim_start, (trim_end - ci.trim_start).max(0.0)) + /// The clip's content start (trim_start) and window length, as raw magnitudes in the clip's own + /// content domain — seconds for audio/video/vector, beats for MIDI. The drag/preview math below + /// works in that domain throughout, converting to the timeline only at the edges. + fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_content: ClipDuration, _bpm: f64) -> (f64, f64) { + let trim_end = ci.trim_end.map_or(clip_content.native(), |t| t.raw()); + let start = ci.trim_start.raw(); + (start, (trim_end - start).max(0.0)) } /// Convert pixel x-coordinate to time (seconds) @@ -3255,8 +3269,10 @@ impl TimelinePane { let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move); let mut ranges: Vec<(Beats, Beats)> = Vec::new(); for (_child_layer_id, ci) in &child_clips { - let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| { - Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start) + let clip_dur = document.clip_trim_duration(&ci.clip_id).unwrap_or_else(|| { + ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + )) }); let mut start = ci.effective_start(); let dur = ci.total_duration(clip_dur, document.tempo_map()); @@ -3329,8 +3345,10 @@ impl TimelinePane { if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) { if let AnyLayer::Video(vl) = video_child { for ci in &vl.clip_instances { - let clip_dur = document.get_clip_duration(&ci.clip_id) - .unwrap_or_else(|| Seconds(ci.trim_end.unwrap_or(1.0) - ci.trim_start)); + let clip_dur = document.clip_trim_duration(&ci.clip_id) + .unwrap_or_else(|| ClipDuration::Seconds(Seconds( + (ci.trim_end.unwrap_or(ContentTime(1.0)) - ci.trim_start).raw(), + ))); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3357,7 +3375,8 @@ impl TimelinePane { ); // 4th elem = clip's TRUE (unclamped) origin x, for correct // hover content time when scrolled partly off the left. - video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); + // Video content is wall-clock, so its content time IS seconds. + video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start.raw(), rect.min.x + sx)); let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; if thumb_display_height > 8.0 { @@ -3370,7 +3389,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, ci.clip_id, - ci.trim_start, + ci.trim_start.raw(), rect.min.x + sx, ex - sx, ci_rect, @@ -3419,7 +3438,7 @@ impl TimelinePane { }; let audio_file_duration = total_frames as f64 / eff_sr as f64; - let clip_dur = audio_clip.content_duration().to_seconds(document.tempo_map()); + let clip_dur = audio_clip.content_duration(); let mut ci_start = ci.effective_start(); if is_move_drag && selection.contains_clip_instance(&ci.id) { ci_start = self.moved_start(ci_start, document.tempo_map(), &document.time_signature, document.framerate); @@ -3470,7 +3489,8 @@ impl TimelinePane { audio_duration: audio_file_duration as f32, sample_rate: eff_sr, clip_start_time: ci_screen_start, - trim_start: ci.trim_start as f32, + // A sampled clip's content time is seconds. + trim_start: ci.trim_start.raw() as f32, tex_width: crate::waveform_gpu::tex_width() as f32, total_frames: total_frames as f32, segment_start_frame: 0.0, @@ -3553,7 +3573,7 @@ impl TimelinePane { let group: Vec<(uuid::Uuid, Beats, Beats)> = clip_instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) .filter_map(|ci| { - let dur = document.get_clip_duration(&ci.clip_id)?; + let dur = document.clip_trim_duration(&ci.clip_id)?; Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map()))) }) .collect(); @@ -3577,8 +3597,11 @@ impl TimelinePane { let shift_beats = |anchor: Beats, secs: f64| tmap.seconds_to_beats(tmap.beats_to_seconds(anchor) + Seconds(secs)); - let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(Seconds::ZERO); - let clip_dur_secs = clip_dur.seconds_to_f64(); + let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(ClipDuration::Seconds(Seconds::ZERO)); + // Raw magnitudes in the clip's own content domain — the drag math below stays in + // that domain and only converts at the timeline edges. + let clip_dur_secs = clip_dur.native(); + let ci_trim_start = ci.trim_start.raw(); let mut start = ci.effective_start(); let mut duration = ci.total_duration(clip_dur, tmap); @@ -3600,25 +3623,25 @@ impl TimelinePane { } } ClipDragType::TrimLeft => { - 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; + 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 { - (trim_end - new_trim).max(0.0) + (trim_end.raw() - new_trim).max(0.0) } else { (clip_dur_secs - new_trim).max(0.0) }; duration = secs_to_beats_at(start, dur_secs); } 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, SNAP_PX_FINE).max(ci.trim_start).min(clip_dur_secs); - let dur_secs = (new_trim_end - ci.trim_start).max(0.0); + let old_trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + 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); } ClipDragType::LoopExtendRight => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.0); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); 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; @@ -3629,8 +3652,8 @@ impl TimelinePane { duration = loop_before + new_right; } ClipDragType::LoopExtendLeft => { - let trim_end = ci.trim_end.unwrap_or(clip_dur_secs); - let content_window_secs = (trim_end - ci.trim_start).max(0.001); + let trim_end = ci.trim_end.map_or(clip_dur_secs, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = secs_to_beats_at(ci.timeline_start, content_window_secs); let current_loop_before = ci.loop_before.unwrap_or(Beats::ZERO); // drag_offset (seconds) as a beats delta at this clip's start. @@ -3691,6 +3714,9 @@ impl TimelinePane { // Track preview trim values for note/waveform rendering. // In Measures mode, derive from beats so they track BPM during live drag. let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm()); + // The instance's trim start as a raw magnitude in the clip's content domain; the + // preview math below stays in that domain. + let ci_trim_start = clip_instance.trim_start.raw(); let mut preview_trim_start = base_trim_start; let mut preview_clip_duration = base_clip_duration; @@ -3706,11 +3732,11 @@ 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, SNAP_PX_FINE) + let desired_trim_start = self.snap_to_grid(ci_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()); + .min(clip_duration.native()); - let new_trim_start = if desired_trim_start < clip_instance.trim_start { + let new_trim_start = if desired_trim_start < ci_trim_start { // Extending left - limit is the content-seconds gap to the previous clip. let max_extend_secs = document.find_max_trim_extend_left( &layer.id(), @@ -3718,25 +3744,25 @@ impl TimelinePane { clip_instance.effective_start(), ).seconds_to_f64(); - let desired_extend = clip_instance.trim_start - desired_trim_start; + let desired_extend = ci_trim_start - desired_trim_start; let actual_extend = desired_extend.min(max_extend_secs); - clip_instance.trim_start - actual_extend + ci_trim_start - actual_extend } else { // Shrinking - no snap needed desired_trim_start }; - let actual_offset = new_trim_start - clip_instance.trim_start; + let actual_offset = new_trim_start - ci_trim_start; // Move start (display seconds) and reduce duration by the clamped offset. instance_start = (document.tempo_map().beats_to_seconds(clip_instance.timeline_start).seconds_to_f64() + actual_offset) .max(0.0); - instance_duration = (clip_duration.seconds_to_f64() - new_trim_start).max(0.0); + instance_duration = (clip_duration.native() - new_trim_start).max(0.0); // Adjust for existing trim_end if let Some(trim_end) = clip_instance.trim_end { - instance_duration = (trim_end - new_trim_start).max(0.0); + instance_duration = (trim_end.raw() - new_trim_start).max(0.0); } // Update preview trim for waveform rendering @@ -3745,14 +3771,14 @@ 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 old_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); 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()); + .max(ci_trim_start) + .min(clip_duration.native()); let new_trim_end = if desired_trim_end > old_trim_end { // Extending right - limit is the content-seconds gap to the next clip. - let current_duration_secs = old_trim_end - clip_instance.trim_start; + let current_duration_secs = old_trim_end - ci_trim_start; let tmap = document.tempo_map(); let current_duration = tmap.seconds_to_beats( tmap.beats_to_seconds(clip_instance.timeline_start) + Seconds(current_duration_secs) @@ -3772,7 +3798,7 @@ impl TimelinePane { desired_trim_end }; - instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0); + instance_duration = (new_trim_end - ci_trim_start).max(0.0); // Update preview clip duration for waveform rendering // (the waveform system uses clip_duration to determine visible range) @@ -3780,8 +3806,8 @@ impl TimelinePane { } ClipDragType::LoopExtendRight => { // Loop extend right: extend clip beyond content window - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window and right-duration are beats-domain timeline spans. @@ -3816,8 +3842,8 @@ impl TimelinePane { ClipDragType::LoopExtendLeft => { // Loop extend left: extend loop_before (pre-loop region) // Snap to multiples of content_window so iterations align with backend - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; // content window is a beats-domain span; guard against zero for division. @@ -4057,7 +4083,7 @@ impl TimelinePane { // Calculate content window for loop detection // Use trimmed content window (preview_trim_start accounts for TrimLeft drag) - let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration.seconds_to_f64()); + let preview_trim_end = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let content_window = (preview_trim_end - preview_trim_start).max(0.0); let is_looping = instance_duration > content_window + 0.001; @@ -4254,7 +4280,7 @@ impl TimelinePane { &video_mgr, &mut self.video_thumbnail_textures, clip_instance.clip_id, - clip_instance.trim_start, + clip_instance.trim_start.raw(), rect.min.x + start_x, end_x - start_x, clip_rect, @@ -4269,7 +4295,7 @@ impl TimelinePane { // clip's TRUE (unclamped) origin x so the hover content time is // correct even when the clip is scrolled partly off the left. if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { - video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); + video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start.raw(), rect.min.x + start_x)); } // Draw border per segment (per loop iteration for looping clips) @@ -5009,18 +5035,23 @@ impl TimelinePane { for clip_instance in clip_instances { if selection.contains_clip_instance(&clip_instance.id) { let clip_duration = effective_clip_duration(document, layer, clip_instance); + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData. + let ci_trim_start = clip_instance.trim_start.raw(); if let Some(clip_duration) = clip_duration { match drag_type { ClipDragType::TrimLeft => { - let old_trim_start = clip_instance.trim_start; + // Raw magnitude in the clip's content domain; re-tagged as a + // ContentTime when it goes back into TrimData below. + let old_trim_start = clip_instance.trim_start.raw(); let old_timeline_start = clip_instance.timeline_start; // 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, SNAP_PX_FINE, - ).max(0.0).min(clip_duration.seconds_to_f64()); + ).max(0.0).min(clip_duration.native()); // Apply overlap prevention when extending left (content-seconds gap). let new_trim_start = if desired_trim_start < old_trim_start { @@ -5050,11 +5081,11 @@ impl TimelinePane { clip_instance.id, lightningbeam_core::actions::TrimType::TrimLeft, lightningbeam_core::actions::TrimData::left( - old_trim_start, + ContentTime(old_trim_start), old_timeline_start, ), lightningbeam_core::actions::TrimData::left( - new_trim_start, + ContentTime(new_trim_start), new_timeline_start, ), )); @@ -5065,10 +5096,10 @@ impl TimelinePane { // Calculate new trim_end based on current duration let current_duration = 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 old_trim_end_val = clip_instance.trim_end.map_or(clip_duration.native(), |t| t.raw()); let desired_trim_end = self.snap_to_grid( 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()); + ).max(ci_trim_start).min(clip_duration.native()); // Apply overlap prevention when extending right (content-seconds gap). let new_trim_end_val = if desired_trim_end > old_trim_end_val { @@ -5085,13 +5116,15 @@ impl TimelinePane { desired_trim_end }; - let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0); + let new_duration = (new_trim_end_val - ci_trim_start).max(0.0); // Convert new duration back to trim_end value - let new_trim_end = if new_duration >= clip_duration.seconds_to_f64() { + let new_trim_end = if new_duration >= clip_duration.native() { None // Use full clip duration } else { - Some((clip_instance.trim_start + new_duration).min(clip_duration.seconds_to_f64())) + Some(ContentTime( + (ci_trim_start + new_duration).min(clip_duration.native()), + )) }; layer_trims @@ -5143,8 +5176,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.0); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.0); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let current_right = clip_instance.timeline_duration.unwrap_or(content_window); // Snap the right edge in the seconds/pixel domain. @@ -5217,8 +5251,9 @@ impl TimelinePane { if let Some(clip_duration) = clip_duration { let tmap = document.tempo_map(); let ts = clip_instance.timeline_start; - let trim_end = clip_instance.trim_end.unwrap_or(clip_duration); - let content_window_secs = (trim_end - clip_instance.trim_start).max(0.001); + let ci_trim_start = clip_instance.trim_start.raw(); + let trim_end = clip_instance.trim_end.map_or(clip_duration, |t| t.raw()); + let content_window_secs = (trim_end - ci_trim_start).max(0.001); let content_window = tmap.seconds_to_beats(tmap.beats_to_seconds(ts) + Seconds(content_window_secs)) - ts; let cw = content_window.beats_to_f64().max(1e-9); let current_loop_before = clip_instance.loop_before.unwrap_or(Beats::ZERO); @@ -6461,7 +6496,7 @@ impl PaneRenderer for TimelinePane { let instances = layer_clips(layer); for inst in instances { if !shared.selection.contains_clip_instance(&inst.id) { continue; } - if let Some(dur) = document.get_clip_duration(&inst.clip_id) { + if let Some(dur) = document.clip_trim_duration(&inst.clip_id) { let eff = inst.effective_duration(dur, document.tempo_map()); let start = document.tempo_map().beats_to_seconds(inst.timeline_start).seconds_to_f64(); let end = document.tempo_map().beats_to_seconds(inst.timeline_start + eff).seconds_to_f64(); @@ -6487,7 +6522,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter() .filter(|ci| shared.selection.contains_clip_instance(&ci.id)) .all(|ci| { - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); // Room to duplicate = seconds gap to the right ≥ this clip's own length. let max_extend_secs = document.find_max_trim_extend_right( @@ -6532,7 +6567,7 @@ impl PaneRenderer for TimelinePane { enabled = instances.iter().all(|ci| { let paste_start = (ci.timeline_start + offset).max(Beats::ZERO); - if let Some(dur) = document.get_clip_duration(&ci.clip_id) { + if let Some(dur) = document.clip_trim_duration(&ci.clip_id) { let eff = ci.effective_duration(dur, document.tempo_map()); document .find_nearest_valid_position( From 6629adc7d29ff09ff4b38cf590874a98711264d5 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 09:31:23 -0400 Subject: [PATCH 06/11] Cycle recording: monitor the MIDI overdub on later passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In merge mode every pass layers into the same clip, so a later pass has to PLAY BACK what earlier passes laid down — otherwise you overdub against silence, which defeats the point of merging (you can't put a hi-hat on a kick you can't hear). Two things stood in the way, and they turned out to be the same bug: - The captured notes only reached the backend's MIDI pool clip at STOP, so during the session the sequencer had nothing to schedule. The wrap now folds the notes captured so far into the pool clip. Their offsets drop straight in: a cycle MIDI recording is anchored at loop_start, so they're already region-relative. - The recording-progress block resizes the clip instance every audio buffer from `playhead - start_time`. The playhead jumps BACKWARDS at a wrap, so that duration collapsed to zero and grew again on every pass. It reset the clip bar to zero each pass (visible), and it shrank the clip instance back to nothing at each wrap (invisible) — so even once the notes were in the pool, the sequencer saw a zero-length instance and scheduled none of them. Fixed at the root: once the transport has wrapped, the recording spans the whole cycle region and STAYS there — it doesn't track the playhead at all. `cycle_loop_len` (set at the first wrap) pins it, which both holds the clip bar at full region length after pass one and keeps the instance stretched across the region so the merged notes get scheduled. Writing the events reuses the clip's existing Vec, so it's allocation-free after the first wrap; mutating the pool from the audio thread is what Command::UpdateMidiClipNotes already does. --- daw-backend/src/audio/engine.rs | 52 ++++++++++++++++++- .../lightningbeam-editor/src/main.rs | 6 ++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 1f6cba8..6e22ad4 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -543,6 +543,47 @@ impl Engine { rec.wrap_at_cycle(le_beats, ls_beats); } + // Overdub monitoring. In merge mode every pass layers onto the same clip, so + // the next pass has to PLAY BACK what was just laid down — otherwise you're + // overdubbing against silence, which defeats the point of merging (you can't + // put a hi-hat on a kick you can't hear). + // + // The notes only reach the pool clip at stop otherwise, so fold what's been + // captured so far into it now, at the boundary. Disjoint field borrows: + // `midi_recording_state` read, `project` written. + if let Some(rec) = self.midi_recording_state.as_ref() { + if let Some(clip) = + self.project.midi_clip_pool.get_clip_mut(rec.clip_id) + { + // Note offsets are relative to `start_time`, which for a cycle + // recording IS the region start — so they're already region-relative + // and drop straight in. + clip.duration = le_beats - ls_beats; + // Reusing the existing Vec keeps this allocation-free after the + // first wrap, which matters on the audio thread. (Mutating the pool + // here is the same thing `Command::UpdateMidiClipNotes` already does + // from this thread.) + clip.events.clear(); + for (start, note, velocity, duration) in rec.get_notes() { + clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity)); + clip.events.push(MidiEvent::note_off( + *start + *duration, + 0, + *note, + 64, + )); + } + clip.events.sort_by(|a, b| { + a.timestamp.partial_cmp(&b.timestamp).unwrap() + }); + } + } + // The clip INSTANCE is sized by the recording-progress block above, which + // now pins it to the whole region once `cycle_loop_len` is set (this wrap + // sets it). That's what lets the sequencer actually schedule the events we + // just wrote — an instance sized to the elapsed-since-start playhead would + // have collapsed back to nothing here. + // An audio recording in progress just completed a pass. This only decides // *whether* the recording becomes multi-take — the takes themselves are cut // geometrically at stop, since the playhead advances before the capture @@ -582,7 +623,16 @@ impl Engine { if let Some(recording) = &self.midi_recording_state { let current_time_secs = Seconds(self.playhead as f64 / self.sample_rate as f64); let current_time = self.tempo_map.seconds_to_beats(current_time_secs); - let duration = current_time - recording.start_time; + // Once the transport has wrapped, the recording covers the WHOLE cycle region and + // stays there — every further pass merges into the same clip rather than + // extending it. Measuring from the playhead instead would reset to zero at each + // wrap (the playhead jumps back), which both made the clip bar restart from zero + // every pass and — because this block also resizes the backend clip instance + // below — shrank the instance back to nothing, so the notes just merged into it + // were never scheduled and you overdubbed against silence. + let duration = recording + .cycle_loop_len + .unwrap_or(current_time - recording.start_time); let notes = recording.get_notes_with_active(current_time); let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( recording.track_id, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 33adcfd..bef3d97 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -6820,7 +6820,11 @@ impl eframe::App for EditorApp { ); } } - // Update the clip's duration so the timeline bar grows + // Update the clip's duration so the timeline bar grows. Once the + // transport has wrapped, the backend reports the whole cycle + // region here and keeps reporting it, so the bar grows through + // the first pass and then holds — every further pass merges into + // the same clip rather than extending it. if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&doc_clip_id) { clip.set_content_duration(ClipDuration::Beats(duration)); } From c62164c3650ccecedb0610a8eff6714e86443af4 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 10:39:46 -0400 Subject: [PATCH 07/11] Cycle recording: MIDI separate-takes mode, and append to existing folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the cycle-recording spec. Three related pieces: MIDI separate takes (Preferences > Audio > "Cycle MIDI recording"): - Each pass becomes its own MIDI clip, folded into a take folder — the same shape audio always gets — instead of merging into one clip. Merge stays the default. - Notes are bucketed by the pass they were played in. The pass counter bumps BETWEEN close_active_notes and the re-note_on at a wrap, so a key held across the boundary has its sounding half filed under the pass that's ending and its re-opened half under the pass that's beginning. Put the bump on either side of that pair and the whole note lands in one pass; there's a test named for exactly that. - A silent INTERIOR pass still yields an empty take, so take N is always pass N — otherwise the numbering silently shifts and "take 3" stops meaning "the third time round". A TRAILING empty pass is dropped: that's what hitting stop shortly after a wrap gives you, a stop artifact rather than a take you played. (Audio already behaved this way via its short-final-take rule.) - Still triggers on the wrap: stop inside the first pass and it's an ordinary single recording, whatever the preference says. Append to an existing take folder (AppendTakesAction): - Cycle-recording over a region that already holds a take folder now ADDS to that folder rather than dropping a second clip on top of it, which stranded the new takes in an overlapping clip you couldn't audition against the old. - "Same region" means same start AND same loop length: resize the cycle region and you get a fresh folder, rather than takes of a different length appended to an existing one, which would break the uniform-take invariant that comping-via-split depends on. - The recording's own clip/instance are throwaway scaffolding here (the takes already live in the backend pools), so they're discarded; the single AppendTakesAction is the whole undoable step. Don't play the region you're recording over: - While recording into a MIDI track, every clip on that track is silenced EXCEPT the one being recorded into. A take folder already sitting in the cycle region was otherwise playing its active take underneath you on every pass, fighting the part you were trying to record. - The recording clip itself is exempt, because in merge mode that's precisely what you want to hear: the overdub you've been building up. Other tracks are untouched. --- daw-backend/src/audio/engine.rs | 113 ++++++- daw-backend/src/audio/export.rs | 3 + daw-backend/src/audio/project.rs | 2 + daw-backend/src/audio/recording.rs | 199 ++++++++++-- daw-backend/src/audio/track.rs | 22 +- daw-backend/src/command/types.rs | 19 ++ .../src/actions/append_takes.rs | 157 ++++++++++ .../lightningbeam-core/src/actions/mod.rs | 2 + .../lightningbeam-core/src/document.rs | 40 +++ .../lightningbeam-editor/src/config.rs | 14 + .../lightningbeam-editor/src/main.rs | 284 ++++++++++++++++++ .../src/panes/piano_roll.rs | 4 +- .../src/preferences/dialog.rs | 38 +++ 13 files changed, 869 insertions(+), 28 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 6e22ad4..2aed285 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -41,6 +41,9 @@ pub struct Engine { /// 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)>, + /// How a cycle MIDI recording treats its passes: merge into one clip (default), or one clip per + /// pass for the editor to fold into a take folder. + cycle_midi_separate_takes: bool, // Lock-free communication command_rx: rtrb::Consumer, @@ -167,6 +170,7 @@ impl Engine { loop_region: None, loop_enabled: false, loop_bounds_frozen: None, + cycle_midi_separate_takes: false, command_rx, midi_command_rx: None, event_tx, @@ -494,6 +498,11 @@ impl Engine { self.project.reset_read_ahead_targets(); // Render the entire project hierarchy into the mix buffer + // Silence everything else on the track being recorded into (see RenderContext). + let recording_midi = self + .midi_recording_state + .as_ref() + .map(|rec| (rec.track_id, rec.clip_id)); self.project.render( &mut self.mix_buffer, &self.audio_pool, @@ -503,6 +512,7 @@ impl Engine { self.sample_rate, self.channels, false, + recording_midi, ); // Copy mix to output @@ -543,7 +553,7 @@ impl Engine { rec.wrap_at_cycle(le_beats, ls_beats); } - // Overdub monitoring. In merge mode every pass layers onto the same clip, so + // Overdub monitoring. In MERGE mode every pass layers onto the same clip, so // the next pass has to PLAY BACK what was just laid down — otherwise you're // overdubbing against silence, which defeats the point of merging (you can't // put a hi-hat on a kick you can't hear). @@ -551,7 +561,12 @@ impl Engine { // The notes only reach the pool clip at stop otherwise, so fold what's been // captured so far into it now, at the boundary. Disjoint field borrows: // `midi_recording_state` read, `project` written. - if let Some(rec) = self.midi_recording_state.as_ref() { + // + // Deliberately NOT done in separate-takes mode: there, each pass is an + // alternative rather than a layer, so hearing the previous take play back + // under you would just be confusing — you'd be playing along with the take + // you're trying to replace. + if let Some(rec) = self.midi_recording_state.as_ref().filter(|_| !self.cycle_midi_separate_takes) { if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(rec.clip_id) { @@ -633,7 +648,19 @@ impl Engine { let duration = recording .cycle_loop_len .unwrap_or(current_time - recording.start_time); - let notes = recording.get_notes_with_active(current_time); + // In separate-takes mode the preview shows only the pass being played now — the + // earlier passes are alternative takes, not layers, so drawing them all on top of + // each other would misrepresent what's being recorded. + let notes = if self.cycle_midi_separate_takes && recording.cycle_loop_len.is_some() { + let mut current = recording + .notes_by_pass(recording.pass_count()) + .pop() + .unwrap_or_default(); + current.extend(recording.active_notes_with_provisional_end(current_time)); + current + } else { + recording.get_notes_with_active(current_time) + }; let _ = self.event_tx.push(AudioEvent::MidiRecordingProgress( recording.track_id, recording.clip_id, @@ -671,6 +698,7 @@ impl Engine { self.sample_rate, self.channels, true, // live_only + None, // no clips are scheduled at all in live_only, so nothing to mute ); output.copy_from_slice(&self.mix_buffer); } @@ -955,6 +983,9 @@ impl Engine { Command::SetLoopEnabled(enabled) => { self.loop_enabled = enabled; } + Command::SetCycleMidiSeparateTakes(separate) => { + self.cycle_midi_separate_takes = separate; + } Command::Stop => { self.playing = false; self.playhead = 0; @@ -3564,6 +3595,76 @@ impl Engine { let clip_id = recording.clip_id; let track_id = recording.track_id; + + // ---- Separate takes: one pool clip per cycle pass ---- + // + // Only when the transport actually wrapped; a recording that stopped inside the first + // pass is an ordinary single recording and falls through to the merge path below, just + // as it does for audio. + if let (true, Some(loop_len)) = + (self.cycle_midi_separate_takes, recording.cycle_loop_len) + { + let loop_start = recording.start_time; // a cycle recording is anchored at the region + let passes = recording.pass_count(); + let buckets = recording.notes_by_pass(passes); + eprintln!( + "[MIDI_RECORDING] Cycle recording (separate takes): {} passes", + passes + ); + + let mut clip_ids: Vec = Vec::with_capacity(buckets.len()); + for (i, bucket) in buckets.iter().enumerate() { + // Pass 0 reuses the clip the recording started on; later passes get fresh ones. + let take_clip_id = if i == 0 { + clip_id + } else { + let id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); + let clip = MidiClip::empty(id, loop_len, format!("Take {}", i + 1)); + self.project.midi_clip_pool.add_existing_clip(clip); + id + }; + + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(take_clip_id) { + clip.events.clear(); + clip.duration = loop_len; + for (start, note, velocity, duration) in bucket { + clip.events.push(MidiEvent::note_on(*start, 0, *note, *velocity)); + clip.events.push(MidiEvent::note_off(*start + *duration, 0, *note, 64)); + } + clip.events + .sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + } + clip_ids.push(take_clip_id); + } + + // Point the track's instance at the take the editor will make active (the last one, + // GarageBand-style) and stretch it over the region. + if let Some(&last) = clip_ids.last() { + if let Some(crate::audio::track::TrackNode::Midi(track)) = + self.project.get_track_mut(track_id) + { + if let Some(instance) = + track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) + { + instance.clip_id = last; + instance.internal_start = Beats::ZERO; + instance.internal_end = loop_len; + instance.external_start = loop_start; + instance.external_duration = loop_len; + } + } + } + self.refresh_clip_snapshot(); + + let _ = self.event_tx.push(AudioEvent::MidiCycleRecordingStopped { + track_id, + clip_ids, + loop_start, + loop_len_beats: loop_len, + }); + return; + } + let notes = recording.get_notes().to_vec(); let note_count = notes.len(); // A cycle MIDI recording is anchored at the region start and every pass overdubs into @@ -3698,6 +3799,12 @@ impl EngineController { let _ = self.command_tx.push(Command::SetLoopEnabled(enabled)); } + /// How a cycle MIDI recording treats its passes: merge into one clip (false, the default), or + /// one clip per pass (true) for the editor to fold into a take folder. + pub fn set_cycle_midi_separate_takes(&mut self, separate: bool) { + let _ = self.command_tx.push(Command::SetCycleMidiSeparateTakes(separate)); + } + /// 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/export.rs b/daw-backend/src/audio/export.rs index 789b9d8..3110024 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -211,6 +211,7 @@ pub fn render_to_memory( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we actually need from this chunk @@ -557,6 +558,7 @@ fn export_mp3>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk @@ -727,6 +729,7 @@ fn export_aac>( settings.sample_rate, settings.channels, false, + None, // export never runs with a recording in flight ); // Calculate how many samples we need from this chunk diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 4cce4a1..c44eafc 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -383,6 +383,7 @@ impl Project { sample_rate: u32, channels: u32, live_only: bool, + recording_midi: Option<(TrackId, MidiClipId)>, ) { output.fill(0.0); @@ -391,6 +392,7 @@ impl Project { // Create initial render context let ctx = RenderContext { live_only, + recording_midi, ..RenderContext::new(playhead_seconds, tempo_map, sample_rate, channels, output.len()) }; diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 0a292d0..504f69a 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -302,6 +302,13 @@ pub struct MidiRecordingState { /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each /// other with no folding needed. Set only if the transport actually wrapped. pub cycle_loop_len: Option, + /// Which cycle pass is currently being recorded (0-based). Bumped at each wrap. + current_pass: usize, + /// The pass each completed note belongs to, parallel to `completed_notes`. + /// + /// Only meaningful in "separate takes" mode, where each pass becomes its own MIDI clip. Merge + /// mode ignores it — all passes fold into one clip, which is the whole point. + note_pass: Vec, } impl MidiRecordingState { @@ -313,9 +320,25 @@ impl MidiRecordingState { active_notes: HashMap::new(), completed_notes: Vec::new(), cycle_loop_len: None, + current_pass: 0, + note_pass: Vec::new(), } } + /// Record a finished note, tagging it with the pass it was played in. + /// + /// Every completion goes through here so `completed_notes` and `note_pass` can't drift apart. + fn push_completed(&mut self, note: &ActiveMidiNote, end_time: Beats) { + let note_start = note.start_time.max(self.start_time); + self.completed_notes.push(( + note_start - self.start_time, + note.note, + note.velocity, + end_time - note_start, + )); + self.note_pass.push(self.current_pass); + } + pub fn note_on(&mut self, note: u8, velocity: u8, absolute_time: Beats) { self.active_notes.insert(note, ActiveMidiNote { note, velocity, start_time: absolute_time }); } @@ -325,16 +348,38 @@ impl MidiRecordingState { if absolute_time <= self.start_time { return; } - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - absolute_time - note_start, - )); + self.push_completed(&active_note, absolute_time); } } + /// Completed notes grouped by cycle pass — one bucket per pass, in recording order. + /// + /// Used by "separate takes" mode, where each pass becomes its own MIDI clip. + /// + /// An *interior* pass in which nothing was played still yields an empty take, so take N in the + /// folder is always pass N on the transport — otherwise the numbering would silently shift and + /// "take 3" would stop meaning "the third time round". A *trailing* empty pass is dropped + /// though: that's what you get by hitting stop shortly after a wrap, and it's a stop artifact + /// rather than a take you played. (Same reasoning as the audio path's short-final-take rule.) + pub fn notes_by_pass(&self, passes: usize) -> Vec> { + let mut buckets = vec![Vec::new(); passes.max(1)]; + for (note, &pass) in self.completed_notes.iter().zip(self.note_pass.iter()) { + if let Some(bucket) = buckets.get_mut(pass) { + bucket.push(*note); + } + } + // Never drop the only take. + while buckets.len() > 1 && buckets.last().is_some_and(|b| b.is_empty()) { + buckets.pop(); + } + buckets + } + + /// How many cycle passes this recording covered (1 if the transport never wrapped). + pub fn pass_count(&self) -> usize { + self.current_pass + 1 + } + pub fn get_notes(&self) -> &[(Beats, u8, u8, Beats)] { &self.completed_notes } @@ -343,18 +388,28 @@ impl MidiRecordingState { self.completed_notes.len() } + /// The still-held notes, given a provisional duration running to `current_time`. + /// + /// These belong to whatever pass is in progress, so a per-pass view can append them as-is. + pub fn active_notes_with_provisional_end(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { + self.active_notes + .values() + .map(|active| { + let note_start = active.start_time.max(self.start_time); + ( + note_start - self.start_time, + active.note, + active.velocity, + (current_time - note_start).max(Beats::ZERO), + ) + }) + .collect() + } + /// Get all completed notes plus currently-held notes with a provisional duration. pub fn get_notes_with_active(&self, current_time: Beats) -> Vec<(Beats, u8, u8, Beats)> { let mut notes = self.completed_notes.clone(); - for active in self.active_notes.values() { - let note_start = active.start_time.max(self.start_time); - notes.push(( - note_start - self.start_time, - active.note, - active.velocity, - (current_time - note_start).max(Beats::ZERO), - )); - } + notes.extend(self.active_notes_with_provisional_end(current_time)); notes } @@ -366,13 +421,7 @@ impl MidiRecordingState { let active_notes: Vec<_> = self.active_notes.drain().collect(); for (_note_num, active_note) in active_notes { - let note_start = active_note.start_time.max(self.start_time); - self.completed_notes.push(( - note_start - self.start_time, - active_note.note, - active_note.velocity, - end_time - note_start, - )); + self.push_completed(&active_note, end_time); } } @@ -392,7 +441,10 @@ impl MidiRecordingState { .map(|n| (n.note, n.velocity)) .collect(); + // Close first, so a note held across the boundary has its tail attributed to the pass that's + // ending; then advance, so the re-opened half belongs to the pass that's beginning. self.close_active_notes(region_end); + self.current_pass += 1; for (note, velocity) in held { self.note_on(note, velocity, region_start); @@ -502,3 +554,104 @@ mod cycle_tests { assert_eq!(takes[1], vec![5.0, 6.0, 7.0, 8.0]); } } + +#[cfg(test)] +mod midi_cycle_tests { + use super::*; + + /// A MIDI recording anchored at the region start (beat 0), region 4 beats long. + fn rec() -> MidiRecordingState { + MidiRecordingState::new(0, 0, Beats(0.0)) + } + + /// One pass of the transport around a 4-beat region. + fn wrap(r: &mut MidiRecordingState) { + r.wrap_at_cycle(Beats(4.0), Beats(0.0)); + } + + #[test] + fn notes_are_bucketed_by_the_pass_they_were_played_in() { + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + let pitches: Vec> = by_pass + .iter() + .map(|p| p.iter().map(|n| n.1).collect()) + .collect(); + assert_eq!(pitches, vec![vec![60], vec![62], vec![64]]); + } + + #[test] + fn a_note_held_across_a_wrap_splits_between_the_two_passes() { + // The key is still down at the boundary: the sounding half belongs to the pass that's + // ending, and the re-opened half to the pass that's beginning. Getting the pass bump on the + // wrong side of close_active_notes would file the whole note under one pass. + let mut r = rec(); + r.note_on(60, 100, Beats(3.0)); + wrap(&mut r); // still held + r.note_off(60, Beats(1.0)); // released 1 beat into the next pass + + assert_eq!(r.pass_count(), 2); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass[0].len(), 1, "the held half lands in the pass that ended"); + assert_eq!(by_pass[1].len(), 1, "the re-opened half lands in the next pass"); + // Pass 0's half runs from beat 3 to the region end at 4. + assert_eq!(by_pass[0][0].0, Beats(3.0)); + assert_eq!(by_pass[0][0].3, Beats(1.0)); + // Pass 1's half starts at the region start and runs to the release. + assert_eq!(by_pass[1][0].0, Beats(0.0)); + assert_eq!(by_pass[1][0].3, Beats(1.0)); + } + + #[test] + fn a_silent_interior_pass_still_yields_an_empty_take() { + // Take N in the folder must be pass N on the transport, even if nothing was played — else + // the take numbering silently shifts under the user. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + wrap(&mut r); // pass 1: played nothing + r.note_on(64, 100, Beats(1.0)); + r.note_off(64, Beats(2.0)); // pass 2 + + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 3); + assert_eq!(by_pass[1].len(), 0, "the silent pass is still take 2"); + assert_eq!(by_pass[2][0].1, 64); + } + + #[test] + fn a_trailing_empty_pass_is_dropped() { + // Hitting stop shortly after a wrap leaves a pass you never played into. That's a stop + // artifact, not a take — unlike a silent pass in the middle, which was a deliberate rest. + let mut r = rec(); + r.note_on(60, 100, Beats(1.0)); + r.note_off(60, Beats(2.0)); // pass 0 + wrap(&mut r); + r.note_on(62, 100, Beats(1.0)); + r.note_off(62, Beats(2.0)); // pass 1 + wrap(&mut r); // pass 2 begins... and the user hits stop + + assert_eq!(r.pass_count(), 3); + let by_pass = r.notes_by_pass(r.pass_count()); + assert_eq!(by_pass.len(), 2, "the empty trailing pass is not a take"); + } + + #[test] + fn an_empty_recording_still_yields_one_take() { + let mut r = rec(); + wrap(&mut r); + wrap(&mut r); + assert_eq!(r.notes_by_pass(r.pass_count()).len(), 1); + } +} diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index f7da3d2..b79cb1a 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -1,6 +1,6 @@ use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; use super::clip::{AudioClipInstance, AudioClipInstanceId}; -use super::midi::{MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi::{MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; use super::midi_pool::MidiClipPool; use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; @@ -43,6 +43,13 @@ pub struct RenderContext<'a> { /// Used after pause/stop to route note-off tails through the normal group hierarchy /// without re-triggering notes from clips at the paused position. pub live_only: bool, + /// The MIDI recording in progress, if any: (track being recorded to, clip being recorded into). + /// + /// On that track, every OTHER clip is silenced for the duration of the recording. You're playing + /// a part into this region — hearing what's already there (a previous take, say) fighting with + /// what you're playing now is just noise. The clip being recorded into is exempt, because in + /// merge mode that's exactly what you DO want to hear: the overdub you've been building up. + pub recording_midi: Option<(TrackId, MidiClipId)>, } impl<'a> RenderContext<'a> { @@ -61,6 +68,7 @@ impl<'a> RenderContext<'a> { buffer_size, time_stretch: 1.0, live_only: false, + recording_midi: None, } } @@ -864,9 +872,21 @@ impl MidiTrack { let playhead_beats = ctx.playhead_beats(); let buffer_end_beats = ctx.buffer_end_beats(); + // While recording into this track, every clip EXCEPT the one being recorded into is + // silenced. Otherwise a take folder already sitting in the cycle region would play its + // active take underneath you on every pass, fighting the part you're trying to record. + // The recording clip itself is exempt: in merge mode that's the overdub monitoring. + let muted_clip = match ctx.recording_midi { + Some((track_id, clip_id)) if track_id == self.id => Some(clip_id), + _ => None, + }; + // Collect MIDI events from all clip instances that overlap with current beat range let mut currently_active = HashSet::new(); for instance in &self.clip_instances { + if muted_clip.is_some_and(|recording| instance.clip_id != recording) { + continue; + } if instance.overlaps_range(playhead_beats, buffer_end_beats) { currently_active.insert(instance.id); } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index e14a19b..e475179 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -133,6 +133,12 @@ pub enum Command { SetLoopRegion(Option<(Beats, Beats)>), /// Enable/disable wrapping at the cycle region's end. SetLoopEnabled(bool), + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = MERGE: every pass overdubs into one clip. `true` = SEPARATE TAKES: each + /// pass becomes its own MIDI clip, and the editor folds them into a take folder — the same shape + /// audio always gets. + SetCycleMidiSeparateTakes(bool), // Recording commands /// Start recording on a track (track_id, start_time) @@ -313,6 +319,19 @@ pub enum AudioEvent { RecordingProgress(ClipId, Seconds), /// Recording stopped (clip_id, pool_index, waveform) RecordingStopped(ClipId, usize, Vec), + /// A MIDI recording that wrapped the cycle region at least once, in SEPARATE TAKES mode. + /// + /// One MIDI clip per pass, in recording order. (Merge mode emits the ordinary + /// `MidiRecordingStopped` instead — all passes are already folded into the one clip.) + MidiCycleRecordingStopped { + track_id: TrackId, + /// One pool MIDI clip per pass. The first is the clip the recording started on. + clip_ids: Vec, + /// Where the takes sit on the timeline — the cycle region's start. + loop_start: Beats, + /// The region's length in beats: every take spans exactly this. + loop_len_beats: Beats, + }, /// A recording that wrapped the cycle region at least once, and so became multi-take. /// /// Each take spans the full region and they're all the same length (partial passes are padded diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs new file mode 100644 index 0000000..b32626e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs @@ -0,0 +1,157 @@ +//! Append freshly-recorded takes to an existing take folder. +//! +//! Cycle-recording over a region that already holds a take folder should *add* to that folder, not +//! drop a second clip on top of it. Otherwise the takes from your second attempt are stranded in a +//! separate, overlapping clip and you can't audition them against the first. +//! +//! The recorded content already exists in the backend pools by the time this runs (the engine put it +//! there at stop), so this action only touches the document — plus the one backend clip the instance +//! plays, which has to be repointed at the newly-active take. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::clip::{AudioClipType, AudioTake}; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// Action that appends takes to a take-folder clip and selects the last of them. +pub struct AppendTakesAction { + layer_id: Uuid, + /// The instance whose folder is being extended (and whose active take changes). + instance_id: Uuid, + clip_id: Uuid, + /// The takes to add, in recording order. + new_takes: Vec, + + // Stored during execute for rollback. + old_take_count: usize, + old_active_take: Option, + executed: bool, +} + +impl AppendTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, clip_id: Uuid, new_takes: Vec) -> Self { + Self { + layer_id, + instance_id, + clip_id, + new_takes, + old_take_count: 0, + old_active_take: None, + executed: false, + } + } + + /// Swap the instance's backend clip to whatever take the document now says is active. + /// + /// Same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command. + fn resync(&self, backend: &mut BackendContext, document: &Document) -> Result<(), String> { + let instance = document + .get_layer(&self.layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == self.instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + let existing: Option = backend + .clip_instance_to_backend_map + .get(&self.instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(&self.layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, self.instance_id); + } + + backend.add_clip_instance(document, &self.layer_id, &instance)?; + Ok(()) + } +} + +impl Action for AppendTakesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let clip = document + .audio_clips + .get_mut(&self.clip_id) + .ok_or_else(|| format!("Audio clip {} not found", self.clip_id))?; + + let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type else { + return Err("Can only append takes to a take folder".to_string()); + }; + + // Only record the pre-state on the first execute; a redo must not overwrite it with the + // post-state left behind by the previous run. + if !self.executed { + self.old_take_count = takes.len(); + } + takes.extend(self.new_takes.iter().cloned()); + // Renumber so the names stay in step with the take indices the badge shows. + for (i, take) in takes.iter_mut().enumerate() { + take.name = format!("Take {}", i + 1); + } + let new_active = takes.len() - 1; + + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Take folders only exist on audio layers".to_string()); + }; + let instance = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + + if !self.executed { + self.old_active_take = instance.active_take; + } + // Land on the take just recorded, GarageBand-style. + instance.active_take = Some(new_active); + self.executed = true; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(clip) = document.audio_clips.get_mut(&self.clip_id) { + if let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type { + takes.truncate(self.old_take_count); + for (i, take) in takes.iter_mut().enumerate() { + take.name = format!("Take {}", i + 1); + } + } + } + + if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) { + if let Some(instance) = audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == self.instance_id) + { + instance.active_take = self.old_active_take; + } + } + Ok(()) + } + + fn description(&self) -> String { + format!("Record {} take(s)", self.new_takes.len()) + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + self.resync(backend, document) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 153b065..148c1f6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -15,6 +15,7 @@ pub mod reorder_clip_instances; pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; +pub mod append_takes; pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; @@ -53,6 +54,7 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; +pub use append_takes::AppendTakesAction; pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a5ec6b6..a7b8d67 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -932,6 +932,46 @@ impl Document { self.get_clip_duration(clip_id).map(crate::clip::ClipDuration::Seconds) } + /// Find an existing take folder on `layer_id` that a new cycle recording should be appended to. + /// + /// Cycle-recording over a region that already holds a take folder should ADD to that folder + /// rather than drop a second clip on top of it — otherwise the new takes are stranded in a + /// separate, overlapping clip and can't be auditioned against the ones already there. + /// + /// "The same region" means the instance starts at `loop_start` and its folder was recorded + /// against the same loop length. Matching the length too means resizing the cycle region starts + /// a fresh folder rather than appending takes of a different length to an existing one, which + /// would break the uniform-take invariant comping depends on. + /// + /// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a + /// candidate. Returns (instance id, clip id). + pub fn take_folder_at( + &self, + layer_id: &Uuid, + loop_start: Beats, + loop_len: Beats, + exclude: &Uuid, + ) -> Option<(Uuid, Uuid)> { + let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else { + return None; + }; + const EPS: f64 = 1e-6; + audio_layer.clip_instances.iter().find_map(|ci| { + if ci.id == *exclude || (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS { + return None; + } + let clip = self.audio_clips.get(&ci.clip_id)?; + match clip.clip_type { + crate::clip::AudioClipType::TakeFolder { recorded_loop_beats, .. } + if (recorded_loop_beats - loop_len).beats_to_f64().abs() < EPS => + { + Some((ci.id, ci.clip_id)) + } + _ => None, + } + }) + } + /// Resolve a [`ContentTime`] (a trim bound) against the clip it belongs to. /// /// The clip is the only thing that knows whether its content is measured in seconds or beats, so diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a724288..8fb4344 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -35,6 +35,18 @@ pub struct AppConfig { #[serde(default = "defaults::audio_buffer_size")] pub audio_buffer_size: u32, + /// How a cycle MIDI recording treats its passes. + /// + /// `false` (default) = **merge**: every pass overdubs into one clip, and earlier passes play back + /// as you record so you can layer against them. `true` = **separate takes**: each pass becomes + /// its own take in a take folder, exactly as audio always does, and earlier passes stay silent + /// (they're alternatives, not layers). + /// + /// Only applies when the transport actually wraps; a recording that stops inside the first pass + /// is an ordinary single recording either way. + #[serde(default = "defaults::cycle_midi_separate_takes")] + pub cycle_midi_separate_takes: bool, + /// Reopen last session on startup #[serde(default = "defaults::reopen_last_session")] pub reopen_last_session: bool, @@ -90,6 +102,7 @@ impl Default for AppConfig { file_height: defaults::file_height(), scroll_speed: defaults::scroll_speed(), audio_buffer_size: defaults::audio_buffer_size(), + cycle_midi_separate_takes: defaults::cycle_midi_separate_takes(), reopen_last_session: defaults::reopen_last_session(), restore_layout_from_file: defaults::restore_layout_from_file(), debug: defaults::debug(), @@ -296,6 +309,7 @@ mod defaults { pub fn file_height() -> u32 { 600 } pub fn scroll_speed() -> f64 { 1.0 } pub fn audio_buffer_size() -> u32 { 256 } + pub fn cycle_midi_separate_takes() -> bool { false } pub fn reopen_last_session() -> bool { false } pub fn restore_layout_from_file() -> bool { true } pub fn debug() -> bool { false } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index bef3d97..f9bfe5c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1105,6 +1105,18 @@ impl EditingContext { } } +/// A finished cycle recording whose takes belong to a take folder that already exists at that +/// region, queued for [`EditorApp::append_cycle_takes`]. +struct PendingTakeAppend { + layer_id: Uuid, + /// The throwaway clip + instance the recording itself was captured into. + recording_instance_id: Uuid, + recording_clip_id: Uuid, + loop_start: Beats, + loop_len: Beats, + takes: Vec, +} + struct EditorApp { layouts: Vec, current_layout_index: usize, @@ -1213,6 +1225,10 @@ struct EditorApp { metronome_enabled: bool, // Whether metronome clicks during recording count_in_enabled: bool, // Whether count-in fires before recording recording_clips: HashMap, // layer_id -> backend clip_id during recording + /// Cycle takes waiting to be folded into an existing take folder. Queued from the audio-event + /// loop (which holds a borrow on the event queue, so it can't call a `&mut self` method) and + /// drained just after it. + pending_take_appends: Vec, recording_start_time: f64, // Playback time when recording started recording_layer_ids: Vec, // Layers being recorded to (for creating clips) // Asset drag-and-drop state @@ -1581,6 +1597,7 @@ impl EditorApp { metronome_enabled: false, // Metronome off by default count_in_enabled: false, // Count-in off by default recording_clips: HashMap::new(), // No active recording clips + pending_take_appends: Vec::new(), recording_start_time: 0.0, // Will be set when recording starts recording_layer_ids: Vec::new(), // Will be populated when recording starts dragging_asset: None, // No asset being dragged initially @@ -2036,6 +2053,88 @@ impl EditorApp { /// 2. For MIDI: Loads the default instrument /// 3. Stores the bidirectional mapping /// 4. Syncs any existing clips on the layer + /// Fold freshly-recorded cycle takes into an existing take folder covering the same region, if + /// there is one. Returns true if it did. + /// + /// Recording more takes over a region you've already recorded should extend that folder, not + /// stack a second clip on top of it — otherwise the new takes are stranded in an overlapping + /// clip and you can't audition them against the ones already there. + /// + /// The in-progress recording's own clip and instance are throwaway scaffolding in this case (the + /// takes themselves already live in the backend pools), so they're discarded. They were never + /// committed as an action, so there's nothing on the undo stack to unwind — the single + /// `AppendTakesAction` is the whole undoable step. + fn append_cycle_takes( + &mut self, + layer_id: uuid::Uuid, + recording_instance_id: uuid::Uuid, + recording_clip_id: uuid::Uuid, + loop_start: Beats, + loop_len: Beats, + takes: Vec, + ) -> bool { + let Some((target_instance_id, target_clip_id)) = self.action_executor.document().take_folder_at( + &layer_id, + loop_start, + loop_len, + &recording_instance_id, + ) else { + return false; + }; + + // Drop the recording's backend clip; the target instance's own clip gets repointed at the + // new active take by the action's backend sync. + let backend_id = self.clip_instance_to_backend_map.remove(&recording_instance_id); + let track_id = self.layer_to_track_map.get(&layer_id).copied(); + if let (Some(backend_id), Some(track_id), Some(controller_arc)) = + (backend_id, track_id, self.audio_controller.as_ref()) + { + let mut controller = controller_arc.lock().unwrap(); + match backend_id { + lightningbeam_core::action::BackendClipInstanceId::Audio(id) => { + controller.remove_audio_clip(track_id, id) + } + lightningbeam_core::action::BackendClipInstanceId::Midi(id) => { + controller.remove_midi_clip(track_id, id) + } + } + } + + // Discard the scaffolding clip + instance. + { + let doc = self.action_executor.document_mut(); + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + al.clip_instances.retain(|ci| ci.id != recording_instance_id); + } + doc.audio_clips.remove(&recording_clip_id); + } + + let action = lightningbeam_core::actions::AppendTakesAction::new( + layer_id, + target_instance_id, + target_clip_id, + takes, + ); + + if let Some(controller_arc) = self.audio_controller.clone() { + let mut controller = controller_arc.lock().unwrap(); + let mut backend_context = lightningbeam_core::action::BackendContext { + audio_controller: Some(&mut *controller), + layer_to_track_map: &self.layer_to_track_map, + clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map, + }; + if let Err(e) = self + .action_executor + .execute_with_backend(Box::new(action), &mut backend_context) + { + eprintln!("Failed to append cycle takes: {}", e); + } + } + + self.autosave.pending_event = true; + true + } + fn sync_audio_layers_to_backend(&mut self) { use lightningbeam_core::layer::{AnyLayer, AudioLayerType}; @@ -2052,6 +2151,9 @@ impl EditorApp { let mut controller = controller_arc.lock().unwrap(); controller.set_loop_region(region); controller.set_loop_enabled(enabled); + // Cycle MIDI mode is a *preference*, not document state, so it rides along here + // rather than through an action. + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); } } @@ -6570,6 +6672,35 @@ impl eframe::App for EditorApp { self.autosave.pending_event = true; let last_take = takes.len() - 1; + let new_takes: Vec = takes + .iter() + .map(|&(pool_index, _)| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + if self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, &instance_id) + .is_some() + { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: instance_id, + recording_clip_id: clip_id, + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_clips.retain(|_, &mut cid| cid != backend_clip_id); + ctx.request_repaint(); + continue; + } + // Promote the in-progress recording clip to a take folder. { let doc = self.action_executor.document_mut(); @@ -6844,6 +6975,141 @@ impl eframe::App for EditorApp { } ctx.request_repaint(); } + AudioEvent::MidiCycleRecordingStopped { track_id, clip_ids, loop_start, loop_len_beats } => { + println!("🎹 MIDI cycle recording stopped: {} takes", clip_ids.len()); + + // Pull every take's events into the cache — the user can switch to any of + // them, not just the active one. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for &take_clip_id in &clip_ids { + if let Ok(data) = controller.query_midi_clip(track_id, take_clip_id) { + self.midi_event_cache.insert(take_clip_id, data.events); + } + } + } + + let layer_id = self.track_to_layer_map.get(&track_id).copied(); + if let (Some(layer_id), Some(&first_clip_id)) = (layer_id, clip_ids.first()) { + // The doc clip is the one the recording started on — which the backend + // reused as take 1. + let doc_clip_id = self.action_executor.document() + .audio_clip_by_midi_clip_id(first_clip_id) + .map(|(id, _)| id); + + if let Some(doc_clip_id) = doc_clip_id { + self.autosave.pending_event = true; + let last_take = clip_ids.len() - 1; + + let recording_instance_id = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).map(|ci| ci.id) + } else { None }); + + let new_takes: Vec = clip_ids + .iter() + .map(|&mid| lightningbeam_core::clip::AudioTake { + // Renumbered by the folder that ends up owning them. + name: String::new(), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + }) + .collect(); + + // If this region already holds a take folder, extend it rather + // than dropping a second clip on top. Queued and run just after + // this loop, which holds a borrow on the event queue. + let existing_folder = recording_instance_id.filter(|rec_inst| { + self.action_executor.document() + .take_folder_at(&layer_id, loop_start, loop_len_beats, rec_inst) + .is_some() + }); + if let Some(rec_inst) = existing_folder { + self.pending_take_appends.push(PendingTakeAppend { + layer_id, + recording_instance_id: rec_inst, + recording_clip_id: doc_clip_id, + loop_start, + loop_len: loop_len_beats, + takes: new_takes, + }); + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + continue; + } + + { + let doc = self.action_executor.document_mut(); + if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) { + clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { + takes: clip_ids.iter().enumerate().map(|(i, &mid)| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + } + }).collect(), + recorded_loop_beats: loop_len_beats, + }; + // MIDI takes are beats-domain, and every take spans exactly + // one cycle region. + clip.set_content_duration(ClipDuration::Beats(loop_len_beats)); + clip.name = format!("Cycle recording ({} takes)", clip_ids.len()); + } + + // Anchor to the region and select the most recent take. + // timeline_duration stays None on purpose — pinning it would + // make a later tempo change loop the take's content to fill + // the span instead of letting it drift naturally. + if let Some(AnyLayer::Audio(al)) = doc.get_layer_mut(&layer_id) { + if let Some(inst) = al.clip_instances.iter_mut().find(|ci| ci.clip_id == doc_clip_id) { + inst.timeline_start = loop_start; + inst.timeline_duration = None; + inst.trim_start = daw_backend::ContentTime::ZERO; + inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64())); + inst.active_take = Some(last_take); + } + } + } + + // Commit the whole cycle-record session as ONE undoable action. + // The backend instance was mapped during MidiRecordingProgress and + // the engine already repointed it at the active take. + let instance = self.action_executor.document() + .get_layer(&layer_id) + .and_then(|l| if let AnyLayer::Audio(al) = l { + al.clip_instances.iter().find(|ci| ci.clip_id == doc_clip_id).cloned() + } else { None }); + if let Some(instance) = instance { + match (self.layer_to_track_map.get(&layer_id).copied(), + self.clip_instance_to_backend_map.get(&instance.id).copied()) { + (Some(tid), Some(backend_id)) => { + let action = lightningbeam_core::actions::AddClipInstanceAction::already_applied( + layer_id, instance, tid, backend_id, + ); + self.action_executor.push_applied(Box::new(action)); + } + _ => self.media_modified = true, + } + } + } + } + + // Clear recording state, same as the single-clip MIDI path. + if let Some(&layer_id) = self.track_to_layer_map.get(&track_id) { + self.recording_layer_ids.retain(|id| *id != layer_id); + self.recording_clips.remove(&layer_id); + } + if self.recording_layer_ids.is_empty() { + self.is_recording = false; + self.recording_clips.clear(); + } + ctx.request_repaint(); + } AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { println!("🎹 MIDI recording stopped: track={:?}, clip_id={}, {} notes", track_id, clip_id, note_count); @@ -6990,6 +7256,19 @@ impl eframe::App for EditorApp { } + // Cycle takes that landed on an existing take folder. Deferred out of the event loop above, + // which holds a borrow on the event queue and so can't call a `&mut self` method. + for req in std::mem::take(&mut self.pending_take_appends) { + self.append_cycle_takes( + req.layer_id, + req.recording_instance_id, + req.recording_clip_id, + req.loop_start, + req.loop_len, + req.takes, + ); + } + // Update input monitoring based on active layer (only send command when changed) { let should_monitor = self.audio_controller.is_some() && self.active_layer_id.map_or(false, |layer_id| { @@ -7184,6 +7463,11 @@ impl eframe::App for EditorApp { if result.buffer_size_changed { println!("⚠️ Audio buffer size will be applied on next app restart"); } + // Cycle MIDI mode takes effect immediately — no restart needed, unlike the buffer size. + if let Some(ref controller_arc) = self.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.set_cycle_midi_separate_takes(self.config.cycle_midi_separate_takes); + } // Apply new keybindings if changed if let Some(new_keymap) = result.new_keymap { self.keymap = new_keymap; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index fa3cd2e..5586901 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -465,7 +465,9 @@ impl PianoRollPane { if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer(&layer_id) { for instance in &audio_layer.clip_instances { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { - if let AudioClipType::Midi { midi_clip_id } = clip.clip_type { + // Resolve through the instance's active take, so a MIDI take folder edits + // whichever take it's actually playing. + if let Some(midi_clip_id) = clip.resolved_midi_clip_id(instance.active_take) { let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); // A MIDI clip's content time IS beats, which is what the piano roll's // x-axis uses. diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 7ef5391..8b19e6f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -55,6 +55,7 @@ struct PreferencesState { file_height: u32, scroll_speed: f64, audio_buffer_size: u32, + cycle_midi_separate_takes: bool, reopen_last_session: bool, restore_layout_from_file: bool, debug: bool, @@ -72,6 +73,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { file_height: config.file_height, scroll_speed: config.scroll_speed, audio_buffer_size: config.audio_buffer_size, + cycle_midi_separate_takes: config.cycle_midi_separate_takes, reopen_last_session: config.reopen_last_session, restore_layout_from_file: config.restore_layout_from_file, debug: config.debug, @@ -91,6 +93,7 @@ impl Default for PreferencesState { file_height: 600, scroll_speed: 1.0, audio_buffer_size: 256, + cycle_midi_separate_takes: false, reopen_last_session: false, restore_layout_from_file: true, debug: false, @@ -543,6 +546,39 @@ impl PreferencesDialog { }); ui.label("Requires app restart to take effect"); + + ui.separator(); + + ui.horizontal(|ui| { + ui.label("Cycle MIDI recording:"); + + egui::ComboBox::from_id_salt("cycle_midi_mode") + .selected_text(if self.working_prefs.cycle_midi_separate_takes { + "Separate takes" + } else { + "Merge" + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + false, + "Merge", + ) + .on_hover_text( + "Every pass overdubs into one clip, and earlier passes play back as \ + you record so you can layer against them.", + ); + ui.selectable_value( + &mut self.working_prefs.cycle_midi_separate_takes, + true, + "Separate takes", + ) + .on_hover_text( + "Each pass becomes its own take in a take folder, as audio always \ + does. Earlier passes stay silent — they're alternatives, not layers.", + ); + }); + }); }); } @@ -641,6 +677,7 @@ impl PreferencesDialog { temp_config.file_height = self.working_prefs.file_height; temp_config.scroll_speed = self.working_prefs.scroll_speed; temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size; + temp_config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; temp_config.reopen_last_session = self.working_prefs.reopen_last_session; temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; temp_config.debug = self.working_prefs.debug; @@ -675,6 +712,7 @@ impl PreferencesDialog { config.file_height = self.working_prefs.file_height; config.scroll_speed = self.working_prefs.scroll_speed; config.audio_buffer_size = self.working_prefs.audio_buffer_size; + config.cycle_midi_separate_takes = self.working_prefs.cycle_midi_separate_takes; config.reopen_last_session = self.working_prefs.reopen_last_session; config.restore_layout_from_file = self.working_prefs.restore_layout_from_file; config.debug = self.working_prefs.debug; From 6924fc0ffe456ce1f23ac6c49d929b959d95d989 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 12:54:23 -0400 Subject: [PATCH 08/11] Take management: move takes onto the clip instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take management should be per clip instance — deleting a take from one half of a comped split shouldn't pull it out from under the other half. That's cleanest if the takes themselves live on the instance rather than the clip, so they do now. AudioClipType::TakeFolder is gone entirely. A clip is plain Sampled/Midi content again, and an instance with a non-empty `takes` list simply OVERRIDES it with whichever take is active. Splitting already clones the instance, so each half gets its own take list for free — no index remapping across instances, no copy-on-write, no shared-state surprise — and comping still works, because the halves can still each select a different take. It collapsed machinery too: resolve() moved from the clip to the instance, and owns_audio_pool_index went back to a one-liner. Management (DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction): - Right-click a clip with more than one take: `Delete ""` and "Delete Unused Takes". Deletion is named after the take that's PLAYING rather than being a generic entry, so you pick the victim by selecting it — one clear act instead of hunting a small trash icon in a list (which is where this started, and it was fiddly). - Double-click a take in the dropdown to rename it in place. - What happens to the selection on delete is the subtle part, and there's a test per case: deleting a take BELOW the active one shifts the selection down so you keep hearing the same take; deleting the ACTIVE take lands on whatever slid into its place (not silently back to take 1); deleting the LAST take steps back one. The only take can't be deleted at all — the menu item isn't offered. - Deleted takes' audio stays in the pool: undo has to put it back, and the other half of a split may still be playing it. Fixes: - A recording that stopped before the loop came round wasn't joining an existing take list — it landed as a separate overlapping clip. Trigger- on-wrap is right for the FIRST recording, but once takes exist there, a further run is plainly another take however short. The engine can't know that (it's document state), so the editor passes `force_takes` with the start-recording command and the run is cut and padded to the region even with zero wraps. This forced cycle_loop_len and `wrapped` apart on the MIDI side: the region length has to be known from the start, but the clip should only pin to full-region length AFTER a pass completes, or the bar jumps to full width the moment you hit record. - Recording a second take left BOTH sounding. append_cycle_takes tore down the recording's backend clip by looking it up in clip_instance_to_backend_map — but on the audio path the recording instance isn't in that map yet; it's only added during promotion, which the append path skips. The event already carries the engine's clip id, so it's handed over explicitly now. - The take badge is hidden when there's only one take — no choice to make. --- daw-backend/src/audio/engine.rs | 60 ++- daw-backend/src/audio/recording.rs | 55 ++- daw-backend/src/command/types.rs | 7 +- .../lightningbeam-core/src/action.rs | 2 +- .../src/actions/append_takes.rs | 60 +-- .../src/actions/loop_clip_instances.rs | 2 +- .../src/actions/manage_takes.rs | 419 ++++++++++++++++++ .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/move_clip_instances.rs | 4 +- .../src/actions/split_clip_instance.rs | 4 +- .../src/actions/trim_clip_instances.rs | 4 +- .../lightningbeam-core/src/clip.rs | 314 +++++++------ .../lightningbeam-core/src/document.rs | 20 +- .../lightningbeam-editor/src/main.rs | 80 ++-- .../lightningbeam-editor/src/mobile/icons.rs | 1 + .../src/panes/asset_library.rs | 38 +- .../src/panes/infopanel.rs | 6 - .../src/panes/piano_roll.rs | 2 +- .../src/panes/timeline.rs | 162 +++++-- 19 files changed, 905 insertions(+), 337 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2aed285..b129a5f 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -645,8 +645,10 @@ impl Engine { // every pass and — because this block also resizes the backend clip instance // below — shrank the instance back to nothing, so the notes just merged into it // were never scheduled and you overdubbed against silence. + // Pinned only once a pass has actually completed — until then the bar still grows. let duration = recording .cycle_loop_len + .filter(|_| recording.wrapped) .unwrap_or(current_time - recording.start_time); // In separate-takes mode the preview shows only the pass being played now — the // earlier passes are alternative takes, not layers, so drawing them all on top of @@ -1490,9 +1492,9 @@ impl Engine { None => {} } } - Command::StartRecording(track_id, start_time) => { + Command::StartRecording(track_id, start_time, force_takes) => { // Start recording on the specified track - self.handle_start_recording(track_id, start_time); + self.handle_start_recording(track_id, start_time, force_takes); } Command::StopRecording => { // Stop the current recording @@ -1510,9 +1512,9 @@ impl Engine { recording.resume(); } } - Command::StartMidiRecording(track_id, clip_id, start_time) => { + Command::StartMidiRecording(track_id, clip_id, start_time, force_takes) => { // Start MIDI recording on the specified track - self.handle_start_midi_recording(track_id, clip_id, start_time); + self.handle_start_midi_recording(track_id, clip_id, start_time, force_takes); } Command::StopMidiRecording => { eprintln!("[ENGINE] Received StopMidiRecording command"); @@ -3264,7 +3266,7 @@ impl Engine { } /// Handle starting a recording - fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats) { + fn handle_start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) { use crate::io::WavWriter; use std::env; @@ -3348,6 +3350,7 @@ impl Engine { loop_len_frames: (le - ls).max(0) as usize, lead_pad_frames: (self.playhead - ls).max(0) as usize, wrap_count: 0, + force_takes, } }) } else { @@ -3552,12 +3555,23 @@ impl Engine { } /// Handle starting MIDI recording - fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { + fn handle_start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) { // Check if track exists and is a MIDI track if let Some(crate::audio::track::TrackNode::Midi(_)) = self.project.get_track_mut(track_id) { // Create MIDI recording state let mut recording_state = MidiRecordingState::new(track_id, clip_id, start_time); + // Note the cycle region up front. `wrapped` stays false until a pass actually completes, + // so the clip bar still grows with the playhead through the first pass. + if self.loop_enabled { + if let Some((ls, le)) = self.loop_region { + if le > ls { + recording_state.cycle_loop_len = Some(le - ls); + recording_state.force_takes = force_takes; + } + } + } + // Inject any notes currently held on this track (pressed during count-in pre-roll) // so they start at t=0 of the recording rather than being lost if let Some(held) = self.midi_held_notes.get(&track_id) { @@ -3598,12 +3612,13 @@ impl Engine { // ---- Separate takes: one pool clip per cycle pass ---- // - // Only when the transport actually wrapped; a recording that stopped inside the first - // pass is an ordinary single recording and falls through to the merge path below, just - // as it does for audio. - if let (true, Some(loop_len)) = - (self.cycle_midi_separate_takes, recording.cycle_loop_len) - { + // Normally only when the transport actually wrapped: a recording that stopped inside the + // first pass is an ordinary single recording and falls through to the merge path below, + // just as it does for audio. `force_takes` overrides that, because the region already + // holds takes and this is another one however short it ran. + let takes_mode = self.cycle_midi_separate_takes + && (recording.wrapped || recording.force_takes); + if let (true, Some(loop_len)) = (takes_mode, recording.cycle_loop_len) { let loop_start = recording.start_time; // a cycle recording is anchored at the region let passes = recording.pass_count(); let buckets = recording.notes_by_pass(passes); @@ -3667,10 +3682,11 @@ impl Engine { let notes = recording.get_notes().to_vec(); let note_count = notes.len(); - // A cycle MIDI recording is anchored at the region start and every pass overdubs into - // the same clip (MERGE), so the clip is exactly one region long — not however long the - // user held the record button, which would run past the loop end. - let recording_duration = match recording.cycle_loop_len { + // A cycle MIDI recording that came round is anchored at the region start and every pass + // overdubs into the same clip (MERGE), so the clip is exactly one region long — not + // however long the user held the record button, which would run past the loop end. One + // that stopped inside the first pass is just an ordinary recording. + let recording_duration = match recording.cycle_loop_len.filter(|_| recording.wrapped) { Some(loop_len) => loop_len, None => end_time - recording.start_time, }; @@ -4206,8 +4222,11 @@ impl EngineController { } /// Start recording on a track - pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartRecording(track_id, start_time)); + /// `force_takes`: cut takes even if the transport never wraps, because the cycle region already + /// holds takes and this recording is another one. Whether that's so is document state, so only + /// the editor can answer it. + pub fn start_recording(&mut self, track_id: TrackId, start_time: Beats, force_takes: bool) { + let _ = self.command_tx.push(Command::StartRecording(track_id, start_time, force_takes)); } /// Stop the current recording @@ -4226,8 +4245,9 @@ impl EngineController { } /// Start MIDI recording on a track - pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats) { - let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time)); + /// `force_takes`: see [`EngineController::start_recording`]. + pub fn start_midi_recording(&mut self, track_id: TrackId, clip_id: MidiClipId, start_time: Beats, force_takes: bool) { + let _ = self.command_tx.push(Command::StartMidiRecording(track_id, clip_id, start_time, force_takes)); } /// Stop the current MIDI recording diff --git a/daw-backend/src/audio/recording.rs b/daw-backend/src/audio/recording.rs index 504f69a..652d603 100644 --- a/daw-backend/src/audio/recording.rs +++ b/daw-backend/src/audio/recording.rs @@ -24,9 +24,20 @@ pub struct CycleRecordInfo { /// punch-in (record while already rolling); take 1 gets this much silence prepended so it still /// spans the whole region. pub lead_pad_frames: usize, - /// How many times the transport wrapped during this recording. Zero means the user stopped - /// before completing a pass, which stays an ordinary single recording. + /// How many times the transport wrapped during this recording. Zero normally means the user + /// stopped before completing a pass, which stays an ordinary single recording — unless + /// `force_takes` says otherwise. pub wrap_count: usize, + /// Cut takes even if the transport never wrapped. + /// + /// Set when the region already holds takes: a further recording there is another take, however + /// short, and it gets padded out to the region like any partial pass. Without this a run that + /// stopped before the loop came round would land as a separate overlapping clip instead of + /// joining the take list. + /// + /// The editor decides this at record start, because whether takes already exist is document + /// state the engine can't see. + pub force_takes: bool, } /// Min/max waveform peaks for a finished buffer of interleaved samples. @@ -130,7 +141,7 @@ impl RecordingState { /// single recording, which keeps the existing path untouched). pub fn slice_takes(&self) -> Option>> { let cycle = self.cycle?; - if cycle.wrap_count == 0 || cycle.loop_len_frames == 0 { + if (cycle.wrap_count == 0 && !cycle.force_takes) || cycle.loop_len_frames == 0 { return None; } @@ -295,13 +306,19 @@ pub struct MidiRecordingState { active_notes: HashMap, /// Completed notes: (time_offset, note, velocity, duration) — all times in beats pub completed_notes: Vec<(Beats, u8, u8, Beats)>, - /// The cycle region's length in beats, when recording into a cycle. + /// The cycle region's length in beats, if one was armed at record start. /// /// A cycle MIDI recording is anchored at the region start (`start_time == loop_start`), which is /// what makes MERGE fall out for free: the transport always wraps back into the region, so every /// note's offset already lands inside `[0, loop_len)` and successive passes overdub onto each - /// other with no folding needed. Set only if the transport actually wrapped. + /// other with no folding needed. pub cycle_loop_len: Option, + /// Whether the transport actually came round. Distinct from `cycle_loop_len`, which only says a + /// region was armed: the clip only pins to the full region once a pass has completed, so until + /// then the bar still grows with the playhead. + pub wrapped: bool, + /// Cut takes even if the transport never wrapped — see [`CycleRecordInfo::force_takes`]. + pub force_takes: bool, /// Which cycle pass is currently being recorded (0-based). Bumped at each wrap. current_pass: usize, /// The pass each completed note belongs to, parallel to `completed_notes`. @@ -320,6 +337,8 @@ impl MidiRecordingState { active_notes: HashMap::new(), completed_notes: Vec::new(), cycle_loop_len: None, + wrapped: false, + force_takes: false, current_pass: 0, note_pass: Vec::new(), } @@ -450,9 +469,10 @@ impl MidiRecordingState { self.note_on(note, velocity, region_start); } - // The transport wrapped, so this is a cycle recording: the clip spans the whole region - // rather than however long the user happened to hold the record button. + // A pass has completed, so from here the clip spans the whole region rather than however long + // the user happens to hold the record button. self.cycle_loop_len = Some(region_end - region_start); + self.wrapped = true; } } @@ -462,6 +482,14 @@ mod cycle_tests { /// A recording state holding `audio_data`, armed for cycle recording. Mono, 100 Hz, so a frame /// is a sample and 5 frames is 50 ms (exactly the min-take threshold). + fn rec_forced(audio: Vec, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { + let mut r = rec(audio, loop_len_frames, lead_pad_frames, wraps); + if let Some(c) = r.cycle.as_mut() { + c.force_takes = true; + } + r + } + fn rec(audio: Vec, loop_len_frames: usize, lead_pad_frames: usize, wraps: usize) -> RecordingState { let mut r = RecordingState::new( 0, @@ -480,6 +508,7 @@ mod cycle_tests { loop_len_frames, lead_pad_frames, wrap_count: wraps, + force_takes: false, }); r } @@ -492,6 +521,18 @@ mod cycle_tests { assert!(r.slice_takes().is_none()); } + #[test] + fn force_takes_makes_a_partial_pass_a_take() { + // Recording over a region that already holds takes: this run is another take however short + // it ran, so it's cut and padded like any partial pass rather than landing as a separate + // overlapping clip. Two real frames of an 8-frame region -> one take, silence for the rest. + let takes = rec_forced(vec![1.0, 2.0], 8, 0, 0) + .slice_takes() + .expect("forced takes"); + assert_eq!(takes.len(), 1); + assert_eq!(takes[0], vec![1.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + } + #[test] fn takes_are_cut_at_exact_loop_multiples() { // 12 frames of audio, 4-frame loop, started at the region start => 3 clean takes. diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index e475179..a87d58a 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -142,7 +142,9 @@ pub enum Command { // Recording commands /// Start recording on a track (track_id, start_time) - StartRecording(TrackId, Beats), + /// (track, start_time, force_takes — cut takes even if the transport never wraps, because the + /// region already holds takes and this is another one) + StartRecording(TrackId, Beats, bool), /// Stop the current recording StopRecording, /// Pause the current recording @@ -152,7 +154,8 @@ pub enum Command { // MIDI Recording commands /// Start MIDI recording on a track (track_id, clip_id, start_time) - StartMidiRecording(TrackId, MidiClipId, Beats), + /// (track, clip, start_time, force_takes — see [`Command::StartRecording`]) + StartMidiRecording(TrackId, MidiClipId, Beats, bool), /// Stop the current MIDI recording StopMidiRecording, diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 4ac3547..2c96842 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -76,7 +76,7 @@ impl BackendContext<'_> { .get(layer_id) .ok_or_else(|| format!("Layer {} not mapped to backend track", layer_id))?; - let resolved = clip.resolve(instance.active_take); + let resolved = instance.resolve(clip); let content = clip.content_duration(); let internal_start = instance.trim_start; let internal_end = instance diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs index b32626e..24f9bf4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/append_takes.rs @@ -9,17 +9,16 @@ //! plays, which has to be repointed at the newly-active take. use crate::action::{Action, BackendClipInstanceId, BackendContext}; -use crate::clip::{AudioClipType, AudioTake}; +use crate::clip::AudioTake; use crate::document::Document; use crate::layer::AnyLayer; use uuid::Uuid; -/// Action that appends takes to a take-folder clip and selects the last of them. +/// Action that appends takes to an instance's take list and selects the last of them. pub struct AppendTakesAction { layer_id: Uuid, - /// The instance whose folder is being extended (and whose active take changes). + /// The instance whose take list is being extended (and whose active take changes). instance_id: Uuid, - clip_id: Uuid, /// The takes to add, in recording order. new_takes: Vec, @@ -30,11 +29,10 @@ pub struct AppendTakesAction { } impl AppendTakesAction { - pub fn new(layer_id: Uuid, instance_id: Uuid, clip_id: Uuid, new_takes: Vec) -> Self { + pub fn new(layer_id: Uuid, instance_id: Uuid, new_takes: Vec) -> Self { Self { layer_id, instance_id, - clip_id, new_takes, old_take_count: 0, old_active_take: None, @@ -71,32 +69,11 @@ impl AppendTakesAction { impl Action for AppendTakesAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { - let clip = document - .audio_clips - .get_mut(&self.clip_id) - .ok_or_else(|| format!("Audio clip {} not found", self.clip_id))?; - - let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type else { - return Err("Can only append takes to a take folder".to_string()); - }; - - // Only record the pre-state on the first execute; a redo must not overwrite it with the - // post-state left behind by the previous run. - if !self.executed { - self.old_take_count = takes.len(); - } - takes.extend(self.new_takes.iter().cloned()); - // Renumber so the names stay in step with the take indices the badge shows. - for (i, take) in takes.iter_mut().enumerate() { - take.name = format!("Take {}", i + 1); - } - let new_active = takes.len() - 1; - let layer = document .get_layer_mut(&self.layer_id) .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; let AnyLayer::Audio(audio_layer) = layer else { - return Err("Take folders only exist on audio layers".to_string()); + return Err("Takes only exist on audio layers".to_string()); }; let instance = audio_layer .clip_instances @@ -104,31 +81,38 @@ impl Action for AppendTakesAction { .find(|ci| ci.id == self.instance_id) .ok_or_else(|| format!("Clip instance {} not found", self.instance_id))?; + // Only record the pre-state on the first execute; a redo must not overwrite it with the + // post-state left behind by the previous run. if !self.executed { + self.old_take_count = instance.takes.len(); self.old_active_take = instance.active_take; } + + // Number the new takes on from what's already there. Existing names are left alone — the + // user may well have renamed them, and renumbering would clobber that. + let base = instance.takes.len(); + for (i, take) in self.new_takes.iter().enumerate() { + let mut take = take.clone(); + if take.name.is_empty() { + take.name = format!("Take {}", base + i + 1); + } + instance.takes.push(take); + } + // Land on the take just recorded, GarageBand-style. - instance.active_take = Some(new_active); + instance.active_take = Some(instance.takes.len() - 1); self.executed = true; Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { - if let Some(clip) = document.audio_clips.get_mut(&self.clip_id) { - if let AudioClipType::TakeFolder { takes, .. } = &mut clip.clip_type { - takes.truncate(self.old_take_count); - for (i, take) in takes.iter_mut().enumerate() { - take.name = format!("Take {}", i + 1); - } - } - } - if let Some(AnyLayer::Audio(audio_layer)) = document.get_layer_mut(&self.layer_id) { if let Some(instance) = audio_layer .clip_instances .iter_mut() .find(|ci| ci.id == self.instance_id) { + instance.takes.truncate(self.old_take_count); instance.active_take = self.old_active_take; } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index a68795a..4def017 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -141,7 +141,7 @@ impl LoopClipInstancesAction { let external_start = instance.timeline_start - left_duration; let get_backend_clip_id = |inst_id: &Uuid| -> Result { - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { midi_clip_id } => Ok(*midi_clip_id), ResolvedContent::Audio { .. } => { let backend_id = backend.clip_instance_to_backend_map.get(inst_id) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs new file mode 100644 index 0000000..4790a2b --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/manage_takes.rs @@ -0,0 +1,419 @@ +//! Take management: delete and rename the takes on a clip instance. +//! +//! Takes live on the INSTANCE, so both of these are naturally scoped to the one the user clicked — +//! deleting a take from one half of a comped split leaves the other half's list alone. + +use crate::action::{Action, BackendClipInstanceId, BackendContext}; +use crate::clip::{AudioTake, ClipInstance}; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +/// The instance a take action targets, looked up mutably. +fn instance_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + instance_id: &Uuid, +) -> Result<&'a mut ClipInstance, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + let AnyLayer::Audio(audio_layer) = layer else { + return Err("Takes only exist on audio layers".to_string()); + }; + audio_layer + .clip_instances + .iter_mut() + .find(|ci| ci.id == *instance_id) + .ok_or_else(|| format!("Clip instance {} not found", instance_id)) +} + +/// Swap an instance's backend clip to whatever take the document now says is active. +/// +/// The same remove + re-add as `SetActiveTakeAction` — there's no in-place pool-swap command. +fn resync( + backend: &mut BackendContext, + document: &Document, + layer_id: &Uuid, + instance_id: &Uuid, +) -> Result<(), String> { + let instance = document + .get_layer(layer_id) + .and_then(|l| match l { + AnyLayer::Audio(al) => al.clip_instances.iter().find(|ci| ci.id == *instance_id), + _ => None, + }) + .cloned() + .ok_or_else(|| format!("Clip instance {} not found", instance_id))?; + + let existing: Option = backend + .clip_instance_to_backend_map + .get(instance_id) + .copied(); + let track_id = backend.layer_to_track_map.get(layer_id).copied(); + if let (Some(backend_id), Some(track_id)) = (existing, track_id) { + backend.remove_clip_instance(track_id, backend_id, *instance_id); + } + + backend.add_clip_instance(document, layer_id, &instance)?; + Ok(()) +} + +/// Remove a take from an instance's take list. +/// +/// The take's recorded audio/MIDI stays in the backend pool — undo has to be able to put it back, +/// and other instances (the other half of a split, say) may still be playing it. +pub struct DeleteTakeAction { + layer_id: Uuid, + instance_id: Uuid, + take_index: usize, + + // Stored during execute for rollback. + removed: Option, + old_active_take: Option, +} + +impl DeleteTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize) -> Self { + Self { + layer_id, + instance_id, + take_index, + removed: None, + old_active_take: None, + } + } +} + +impl Action for DeleteTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + + if self.take_index >= instance.takes.len() { + return Err(format!("Take {} does not exist", self.take_index + 1)); + } + // An instance with no takes at all would fall back to the clip's own content, which for a + // cycle recording is a take we may just have deleted. Refuse rather than strand it. + if instance.takes.len() == 1 { + return Err("Can't delete the only take".to_string()); + } + + self.old_active_take = instance.active_take; + self.removed = Some(instance.takes.remove(self.take_index)); + + // Everything above the removed take shifts down one, so the selection has to move with it. + // Deleting the *active* take lands on the one that took its place (or the new last take, if + // it was at the end) — that keeps the clip sounding rather than silently picking take 1. + let active = instance.active_take.unwrap_or(0); + instance.active_take = Some(if active > self.take_index { + active - 1 + } else if active == self.take_index { + self.take_index.min(instance.takes.len() - 1) + } else { + active + }); + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(take) = self.removed.take() else { + return Ok(()); + }; + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + let at = self.take_index.min(instance.takes.len()); + instance.takes.insert(at, take); + instance.active_take = self.old_active_take; + Ok(()) + } + + fn description(&self) -> String { + format!("Delete take {}", self.take_index + 1) + } + + fn execute_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + resync(backend, document, &self.layer_id, &self.instance_id) + } + + fn rollback_backend( + &mut self, + backend: &mut BackendContext, + document: &Document, + ) -> Result<(), String> { + resync(backend, document, &self.layer_id, &self.instance_id) + } +} + +/// Throw away every take except the one that's playing. +/// +/// The tidy-up once you've picked your keeper. Scoped to this instance, so on a comped split it +/// prunes the half you clicked and leaves the other half's alternatives intact. +pub struct DeleteUnusedTakesAction { + layer_id: Uuid, + instance_id: Uuid, + + // Stored during execute for rollback. + old_takes: Vec, + old_active_take: Option, +} + +impl DeleteUnusedTakesAction { + pub fn new(layer_id: Uuid, instance_id: Uuid) -> Self { + Self { + layer_id, + instance_id, + old_takes: Vec::new(), + old_active_take: None, + } + } +} + +impl Action for DeleteUnusedTakesAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + if instance.takes.len() < 2 { + return Err("Nothing to delete".to_string()); + } + + let keep = instance.active_take_index(); + self.old_takes = instance.takes.clone(); + self.old_active_take = instance.active_take; + + let kept = instance.takes.remove(keep); + instance.takes.clear(); + instance.takes.push(kept); + instance.active_take = Some(0); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + instance.takes = std::mem::take(&mut self.old_takes); + instance.active_take = self.old_active_take; + Ok(()) + } + + fn description(&self) -> String { + "Delete unused takes".to_string() + } + + // The take that plays doesn't change, so the backend clip is already correct. +} + +/// Rename a take. Document-only — which take *plays* doesn't change, so the backend is untouched. +pub struct RenameTakeAction { + layer_id: Uuid, + instance_id: Uuid, + take_index: usize, + new_name: String, + old_name: String, +} + +impl RenameTakeAction { + pub fn new(layer_id: Uuid, instance_id: Uuid, take_index: usize, new_name: String) -> Self { + Self { + layer_id, + instance_id, + take_index, + new_name, + old_name: String::new(), + } + } +} + +impl Action for RenameTakeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + let take = instance + .takes + .get_mut(self.take_index) + .ok_or_else(|| format!("Take {} does not exist", self.take_index + 1))?; + self.old_name = std::mem::replace(&mut take.name, self.new_name.clone()); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let instance = instance_mut(document, &self.layer_id, &self.instance_id)?; + if let Some(take) = instance.takes.get_mut(self.take_index) { + take.name = self.old_name.clone(); + } + Ok(()) + } + + fn description(&self) -> String { + format!("Rename take to \"{}\"", self.new_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::clip::TakeContent; + use crate::layer::AudioLayer; + + /// A document with one audio layer holding one instance with 4 takes (pools 10..13). + fn doc_with_takes() -> (Document, Uuid, Uuid) { + let mut document = Document::new("Test"); + let clip = crate::clip::AudioClip::new_sampled("Cycle rec", 10, 2.0); + let clip_id = document.add_audio_clip(clip); + + let mut instance = ClipInstance::new(clip_id); + instance.takes = (10..14) + .map(|pool| AudioTake { + name: format!("Take {}", pool - 9), + content: TakeContent::Audio { audio_pool_index: pool }, + }) + .collect(); + let instance_id = instance.id; + + let mut layer = AudioLayer::new("Layer"); + layer.clip_instances.push(instance); + let layer_id = document.root.add_child(AnyLayer::Audio(layer)); + (document, layer_id, instance_id) + } + + fn takes_of(document: &Document, layer_id: &Uuid, instance_id: &Uuid) -> ClipInstance { + let AnyLayer::Audio(al) = document.get_layer(layer_id).unwrap() else { panic!() }; + al.clip_instances.iter().find(|ci| ci.id == *instance_id).unwrap().clone() + } + + #[test] + fn deleting_a_take_below_the_active_one_shifts_the_selection_down() { + // Everything above the removed take shifts down one, so a selection above it has to move + // with it — otherwise the instance silently starts playing a different take. + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(3); // playing pool 13 + } + + DeleteTakeAction::new(layer_id, instance_id, 1) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 3); + assert_eq!(inst.active_take, Some(2), "index shifted down with the take"); + assert_eq!( + inst.takes[inst.active_take_index()].content, + TakeContent::Audio { audio_pool_index: 13 }, + "still playing the same take it was", + ); + } + + #[test] + fn deleting_the_active_take_lands_on_its_replacement() { + // Deleting what you're listening to should hand you the take that took its place, not + // silently jump you back to take 1. + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(1); // playing pool 11 + } + + DeleteTakeAction::new(layer_id, instance_id, 1) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.active_take, Some(1)); + assert_eq!( + inst.takes[1].content, + TakeContent::Audio { audio_pool_index: 12 }, + "the take that slid into the deleted one's place", + ); + } + + #[test] + fn deleting_the_last_take_in_the_list_steps_back() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(3); + } + + DeleteTakeAction::new(layer_id, instance_id, 3) + .execute(&mut document) + .expect("delete"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.active_take, Some(2), "there is no take 4 to land on"); + } + + #[test] + fn the_only_take_cannot_be_deleted() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].takes.truncate(1); + } + assert!(DeleteTakeAction::new(layer_id, instance_id, 0) + .execute(&mut document) + .is_err()); + } + + #[test] + fn undoing_a_delete_puts_the_take_back_where_it_was() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(2); + } + + let mut action = DeleteTakeAction::new(layer_id, instance_id, 1); + action.execute(&mut document).expect("delete"); + action.rollback(&mut document).expect("undo"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 4); + assert_eq!( + inst.takes[1].content, + TakeContent::Audio { audio_pool_index: 11 }, + "restored at its original index", + ); + assert_eq!(inst.active_take, Some(2), "and the selection with it"); + } + + #[test] + fn deleting_unused_takes_keeps_the_one_thats_playing() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + { + let AnyLayer::Audio(al) = document.get_layer_mut(&layer_id).unwrap() else { panic!() }; + al.clip_instances[0].active_take = Some(2); // pool 12 — the keeper + } + + let mut action = DeleteUnusedTakesAction::new(layer_id, instance_id); + action.execute(&mut document).expect("prune"); + + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 1); + assert_eq!(inst.active_take, Some(0)); + assert_eq!( + inst.takes[0].content, + TakeContent::Audio { audio_pool_index: 12 }, + "the take that was playing survives, and nothing else", + ); + + action.rollback(&mut document).expect("undo"); + let inst = takes_of(&document, &layer_id, &instance_id); + assert_eq!(inst.takes.len(), 4); + assert_eq!(inst.active_take, Some(2), "back to what was playing before"); + } + + #[test] + fn renaming_a_take_round_trips() { + let (mut document, layer_id, instance_id) = doc_with_takes(); + let mut action = + RenameTakeAction::new(layer_id, instance_id, 2, "The good one".to_string()); + + action.execute(&mut document).expect("rename"); + assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "The good one"); + + action.rollback(&mut document).expect("undo"); + assert_eq!(takes_of(&document, &layer_id, &instance_id).takes[2].name, "Take 3"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 148c1f6..a848cab 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -16,6 +16,7 @@ pub mod paint_bucket; pub mod remove_effect; pub mod set_cycle_region; pub mod append_takes; +pub mod manage_takes; pub mod set_active_take; pub mod set_document_properties; pub mod set_instance_properties; @@ -55,6 +56,7 @@ pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use set_cycle_region::SetCycleRegionAction; pub use append_takes::AppendTakesAction; +pub use manage_takes::{DeleteTakeAction, DeleteUnusedTakesAction, RenameTakeAction}; pub use set_active_take::SetActiveTakeAction; pub use add_effect::AddEffectAction; pub use add_layer::AddLayerAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index 0439e04..20aa7ef 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -247,7 +247,7 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *new_start); @@ -332,7 +332,7 @@ impl Action for MoveClipInstancesAction { .ok_or_else(|| format!("Audio clip {} not found", instance.clip_id))?; // Handle move based on clip type (restore old position) - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { midi_clip_id } => { // For MIDI: move_clip expects the pool clip ID controller.move_clip(*track_id, *midi_clip_id, *old_start); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index c234510..2f19b85 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -364,7 +364,7 @@ impl Action for SplitClipInstanceAction { .ok_or_else(|| "Audio clip not found".to_string())?; use crate::clip::ResolvedContent; - if matches!(clip.resolve(original_instance.active_take), ResolvedContent::Recording) { + if matches!(original_instance.resolve(clip), ResolvedContent::Recording) { return Err("Cannot split a clip that is currently recording".to_string()); } @@ -455,7 +455,7 @@ impl Action for SplitClipInstanceAction { // Restore based on clip type use crate::clip::ResolvedContent; - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { .. } => { if let Some(crate::action::BackendClipInstanceId::Midi(orig_backend_id)) = backend.clip_instance_to_backend_map.get(&self.instance_id) diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index ef97fee..7e99d94 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -487,7 +487,7 @@ impl Action for TrimClipInstancesAction { .unwrap_or(ContentTime(clip.content_duration().native())); // Handle trim based on clip type - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); @@ -584,7 +584,7 @@ impl Action for TrimClipInstancesAction { }; // Handle trim based on clip type - match &clip.resolve(instance.active_take) { + match &instance.resolve(clip) { ResolvedContent::Midi { midi_clip_id } => { // For MIDI: trim_clip expects the pool clip ID controller.trim_clip(*track_id, *midi_clip_id, clip.trim_range(internal_start, internal_end)); diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 447fbfe..f39da7c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -472,39 +472,20 @@ pub enum AudioClipType { /// Placeholder for a clip that is currently being recorded. /// The audio_pool_index will be assigned when recording stops. Recording, - /// A folder of alternate takes, produced by cycle recording. - /// - /// Each pass of the transport around the cycle region becomes one take. Every take spans the - /// **full** cycle region (partial passes are padded with silence at capture time), so all takes - /// are the same length and share this clip's `duration` — which in turn means switching takes - /// never changes the clip's geometry, and splitting a take-folder instance yields two halves - /// whose takes still line up. Which take actually sounds is per-*instance* - /// ([`ClipInstance::active_take`]), not per-clip, so a split can play take 1 on the left and - /// take 3 on the right. That's comping. - TakeFolder { - /// The takes, in the order they were recorded. Never empty in practice. - takes: Vec, - /// The cycle region's length in beats at the time of recording. - /// - /// Audio takes are segmented geometrically (by sample count), so they're only meaningful - /// against the tempo they were cut at. Keeping the recorded length lets a future - /// time-stretch/conform feature reconcile the takes if the tempo changes underneath them. - recorded_loop_beats: Beats, - }, } -/// One take in a [`AudioClipType::TakeFolder`]. +/// One take of a cycle recording — see [`ClipInstance::takes`]. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AudioTake { - /// Display name, e.g. "Take 1". + /// Display name, e.g. "Take 1". User-editable. pub name: String, /// The recorded content this take points at. pub content: TakeContent, } -/// What a take actually holds. A folder's takes are all the same kind — one cycle-record session +/// What a take actually holds. An instance's takes are all the same kind — one cycle-record session /// captures either audio or MIDI, never a mix. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub enum TakeContent { /// Sampled audio: index into the audio pool. Audio { audio_pool_index: usize }, @@ -608,18 +589,8 @@ impl AudioClip { } /// Whether this clip's `duration` is measured in beats (MIDI) rather than seconds. - /// - /// A take folder inherits the domain of its takes, which are all the same kind — one - /// cycle-record session captures either audio or MIDI, never a mix. An empty folder can't - /// happen in practice; call it seconds so the fallback is the common case. fn is_midi_domain(&self) -> bool { - match &self.clip_type { - AudioClipType::Midi { .. } => true, - AudioClipType::Sampled { .. } | AudioClipType::Recording => false, - AudioClipType::TakeFolder { takes, .. } => { - matches!(takes.first().map(|t| &t.content), Some(TakeContent::Midi { .. })) - } - } + matches!(self.clip_type, AudioClipType::Midi { .. }) } /// Create a new sampled audio clip @@ -706,34 +677,11 @@ impl AudioClip { } } - /// The clip's takes, if it's a take folder. - pub fn takes(&self) -> Option<&[AudioTake]> { - match &self.clip_type { - AudioClipType::TakeFolder { takes, .. } => Some(takes), - _ => None, - } - } - - /// The take an instance's `active_take` actually selects. + /// The clip's own content, ignoring takes. /// - /// `None` means take 0, which is also what an out-of-range index falls back to — an index can - /// go stale (an old `.beam`, an undo that shrank the folder), and silently playing the first - /// take beats refusing to play anything. - fn take_for(&self, active_take: Option) -> Option<&AudioTake> { - let takes = self.takes()?; - takes - .get(active_take.unwrap_or(0)) - .or_else(|| takes.first()) - } - - /// What this clip plays *for a given instance*, with take folders collapsed to the instance's - /// active take. - /// - /// This is the sanctioned way to ask "what content do I hand the backend for this instance?". - /// Matching on `clip_type` directly will see a `TakeFolder` and have to handle it separately; - /// matching on this won't, because a folder is never a distinct case here — it's just an audio - /// or MIDI clip whose identity depends on which take is active. - pub fn resolve(&self, active_take: Option) -> ResolvedContent { + /// Callers that are handing content to the backend want [`ClipInstance::resolve`] instead — an + /// instance with takes overrides the clip's content with whichever take is active. + pub fn resolve(&self) -> ResolvedContent { match &self.clip_type { AudioClipType::Sampled { audio_pool_index } => ResolvedContent::Audio { audio_pool_index: *audio_pool_index, @@ -742,17 +690,6 @@ impl AudioClip { midi_clip_id: *midi_clip_id, }, AudioClipType::Recording => ResolvedContent::Recording, - AudioClipType::TakeFolder { .. } => match self.take_for(active_take).map(|t| &t.content) { - Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio { - audio_pool_index: *audio_pool_index, - }, - Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi { - midi_clip_id: *midi_clip_id, - }, - // An empty folder has nothing to play. Treat it like a recording placeholder: - // the backend gets nothing, rather than a bogus pool index. - None => ResolvedContent::Recording, - }, } } @@ -787,49 +724,19 @@ impl AudioClip { } } - /// Whether this clip owns the given audio pool index — either as a plain sampled clip, or as - /// *any* take of a take folder. Reverse lookups (backend resource → document clip) must use - /// this: a folder owns one pool file per take, not just the active one. + /// Whether this clip's own content is the given audio pool index. pub fn owns_audio_pool_index(&self, pool_index: usize) -> bool { - match &self.clip_type { - AudioClipType::Sampled { audio_pool_index } => *audio_pool_index == pool_index, - AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { - matches!(t.content, TakeContent::Audio { audio_pool_index } if audio_pool_index == pool_index) - }), - _ => false, - } + self.audio_pool_index() == Some(pool_index) } - /// Whether this clip owns the given backend MIDI clip ID. See [`Self::owns_audio_pool_index`]. + /// Whether this clip's own content is the given backend MIDI clip ID. pub fn owns_midi_clip_id(&self, id: u32) -> bool { - match &self.clip_type { - AudioClipType::Midi { midi_clip_id } => *midi_clip_id == id, - AudioClipType::TakeFolder { takes, .. } => takes.iter().any(|t| { - matches!(t.content, TakeContent::Midi { midi_clip_id } if midi_clip_id == id) - }), - _ => false, - } - } - - /// The audio pool index this *instance* should play. See [`Self::resolve`]. - pub fn resolved_audio_pool_index(&self, active_take: Option) -> Option { - match self.resolve(active_take) { - ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index), - _ => None, - } - } - - /// The backend MIDI clip ID this *instance* should play. See [`Self::resolve`]. - pub fn resolved_midi_clip_id(&self, active_take: Option) -> Option { - match self.resolve(active_take) { - ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id), - _ => None, - } + self.midi_clip_id() == Some(id) } } -/// What a clip instance actually plays, once take folders are resolved to their active take. -/// Produced by [`AudioClip::resolve`]. +/// What a clip instance actually plays, once takes are resolved to the active one. +/// Produced by [`ClipInstance::resolve`]. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ResolvedContent { Audio { audio_pool_index: usize }, @@ -942,14 +849,34 @@ pub struct ClipInstance { #[serde(default, skip_serializing_if = "Option::is_none")] pub loop_before: Option, - /// Which take of a [`AudioClipType::TakeFolder`] clip this instance plays. + /// Alternate takes from cycle recording. Empty = an ordinary instance with no takes. /// - /// Per-instance rather than per-clip so two instances of the same folder — e.g. the two halves - /// of a split — can play different takes. That's how comping works. `None` means take 0; - /// meaningless (and ignored) on non-folder clips. + /// The takes live on the INSTANCE, not the clip, so managing them is per-instance: deleting or + /// renaming a take on one instance leaves every other instance alone. Splitting clones the list + /// along with the rest of the instance, so the two halves get independent take lists — and, + /// since they can each select a different take, comping still falls out for free. + /// + /// Every take spans the full cycle region (partial passes are padded with silence at capture + /// time), so they're all the same length as the clip's own content. That uniformity is what lets + /// a take switch leave the instance's geometry untouched. + /// + /// When non-empty, these OVERRIDE the clip's own content — see [`Self::resolve`]. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub takes: Vec, + + /// Which of [`Self::takes`] plays. `None` (or a stale index) means take 0. /// Default: None #[serde(default, skip_serializing_if = "Option::is_none")] pub active_take: Option, + + /// The cycle region's length in beats when these takes were recorded. + /// + /// Audio takes are cut geometrically (by sample count), so they're only meaningful against the + /// tempo they were recorded at. Keeping the recorded length lets a future time-stretch/conform + /// feature reconcile them if the tempo moves underneath, and lets a new recording tell whether + /// it belongs in this take list or a fresh one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recorded_loop_beats: Option, } /// High 64-bit sentinel used to identify UUIDs that encode a backend audio clip instance ID. @@ -1008,7 +935,9 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + takes: Vec::new(), active_take: None, + recorded_loop_beats: None, } } @@ -1027,7 +956,9 @@ impl ClipInstance { playback_speed: 1.0, gain: 1.0, loop_before: None, + takes: Vec::new(), active_take: None, + recorded_loop_beats: None, } } @@ -1087,6 +1018,55 @@ impl ClipInstance { self } + /// The take this instance plays, if it has any. + /// + /// A `None`/stale `active_take` falls back to take 0 — an index can go stale (an undo that + /// shrank the list, an old `.beam`), and silently playing the first take beats playing nothing. + pub fn active_take(&self) -> Option<&AudioTake> { + self.takes + .get(self.active_take.unwrap_or(0)) + .or_else(|| self.takes.first()) + } + + /// The index [`Self::active_take`] actually resolves to, clamped into range. + pub fn active_take_index(&self) -> usize { + let i = self.active_take.unwrap_or(0); + if i < self.takes.len() { i } else { 0 } + } + + /// What this instance plays: its active take if it has takes, otherwise the clip's own content. + /// + /// This is the sanctioned way to ask "what content do I hand the backend for this instance?". + /// Takes are never a distinct *case* at the call site — an instance with takes is just an audio + /// or MIDI instance whose identity depends on which take is live. + pub fn resolve(&self, clip: &AudioClip) -> ResolvedContent { + match self.active_take().map(|t| &t.content) { + Some(TakeContent::Audio { audio_pool_index }) => ResolvedContent::Audio { + audio_pool_index: *audio_pool_index, + }, + Some(TakeContent::Midi { midi_clip_id }) => ResolvedContent::Midi { + midi_clip_id: *midi_clip_id, + }, + None => clip.resolve(), + } + } + + /// The audio pool index this instance plays. See [`Self::resolve`]. + pub fn resolved_audio_pool_index(&self, clip: &AudioClip) -> Option { + match self.resolve(clip) { + ResolvedContent::Audio { audio_pool_index } => Some(audio_pool_index), + _ => None, + } + } + + /// The backend MIDI clip ID this instance plays. See [`Self::resolve`]. + pub fn resolved_midi_clip_id(&self, clip: &AudioClip) -> Option { + match self.resolve(clip) { + ResolvedContent::Midi { midi_clip_id } => Some(midi_clip_id), + _ => None, + } + } + /// Content window (`trim_end - trim_start`) in the clip's own content domain. /// Used for internal looping calculations. pub fn content_window(&self, clip_content: ClipDuration) -> ClipDuration { @@ -1328,79 +1308,89 @@ mod tests { assert_eq!(instance.gain, 0.8); } - /// Build a take folder of `n` audio takes with the given pool indices. - fn take_folder(pool_indices: &[usize]) -> AudioClip { - let mut clip = AudioClip::new_sampled("Cycle rec", 0, 2.0); - clip.clip_type = AudioClipType::TakeFolder { - takes: pool_indices - .iter() - .enumerate() - .map(|(i, &audio_pool_index)| AudioTake { - name: format!("Take {}", i + 1), - content: TakeContent::Audio { audio_pool_index }, - }) - .collect(), - recorded_loop_beats: Beats(8.0), - }; - clip + /// A clip whose own content is pool 10, plus an instance carrying takes over the given pools. + fn with_takes(pool_indices: &[usize]) -> (AudioClip, ClipInstance) { + let clip = AudioClip::new_sampled("Cycle rec", pool_indices[0], 2.0); + let mut instance = ClipInstance::new(clip.id); + instance.takes = pool_indices + .iter() + .enumerate() + .map(|(i, &audio_pool_index)| AudioTake { + name: format!("Take {}", i + 1), + content: TakeContent::Audio { audio_pool_index }, + }) + .collect(); + instance.recorded_loop_beats = Some(Beats(8.0)); + (clip, instance) } #[test] fn active_take_selects_the_pool_file() { - let clip = take_folder(&[10, 11, 12]); - assert_eq!(clip.resolved_audio_pool_index(Some(0)), Some(10)); - assert_eq!(clip.resolved_audio_pool_index(Some(2)), Some(12)); + let (clip, mut instance) = with_takes(&[10, 11, 12]); + instance.active_take = Some(0); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + instance.active_take = Some(2); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(12)); // None means take 0. - assert_eq!(clip.resolved_audio_pool_index(None), Some(10)); + instance.active_take = None; + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); } #[test] fn out_of_range_take_falls_back_to_the_first() { - // An index can go stale (an old .beam, an undo that shrank the folder). Playing the first - // take beats playing nothing. - let clip = take_folder(&[10, 11]); - assert_eq!(clip.resolved_audio_pool_index(Some(99)), Some(10)); + // An index can go stale (an undo that shrank the list, an old .beam). Playing the first take + // beats playing nothing. + let (clip, mut instance) = with_takes(&[10, 11]); + instance.active_take = Some(99); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(instance.active_take_index(), 0); } #[test] - fn take_folder_owns_every_takes_pool_file() { - // Reverse lookups (backend resource -> document clip) must find the folder via ANY take, - // not just the active one. - let clip = take_folder(&[10, 11, 12]); - assert!(clip.owns_audio_pool_index(10)); - assert!(clip.owns_audio_pool_index(12)); - assert!(!clip.owns_audio_pool_index(13)); + fn an_instance_without_takes_plays_the_clips_own_content() { + let clip = AudioClip::new_sampled("Plain", 42, 2.0); + let instance = ClipInstance::new(clip.id); + assert!(instance.takes.is_empty()); + assert_eq!(instance.resolved_audio_pool_index(&clip), Some(42)); } #[test] - fn midi_take_folder_measures_duration_in_beats() { - // A folder inherits its takes' domain: MIDI takes mean the duration is beats, not seconds. - let mut clip = AudioClip::new_sampled("Cycle rec", 0, 4.0); - clip.clip_type = AudioClipType::TakeFolder { - takes: vec![AudioTake { - name: "Take 1".into(), - content: TakeContent::Midi { midi_clip_id: 7 }, - }], - recorded_loop_beats: Beats(4.0), - }; + fn midi_takes_resolve_to_midi_content() { + let clip = AudioClip::new_midi("Cycle rec", 7, Beats(4.0)); + let mut instance = ClipInstance::new(clip.id); + instance.takes = vec![AudioTake { + name: "Take 1".into(), + content: TakeContent::Midi { midi_clip_id: 7 }, + }]; assert_eq!(clip.content_duration(), ClipDuration::Beats(Beats(4.0))); - assert_eq!(clip.resolved_midi_clip_id(Some(0)), Some(7)); - assert_eq!(clip.resolved_audio_pool_index(Some(0)), None); + assert_eq!(instance.resolved_midi_clip_id(&clip), Some(7)); + assert_eq!(instance.resolved_audio_pool_index(&clip), None); } #[test] - fn takes_are_per_instance_so_a_split_can_comp() { - // The whole point of putting active_take on the instance: two instances of the same folder - // (which is what a split produces) can play different takes. - let clip = take_folder(&[10, 11, 12]); - let mut left = ClipInstance::new(clip.id); - let mut right = left.clone(); + fn splitting_gives_each_half_an_independent_take_list() { + // Takes live on the INSTANCE, so a split (which clones the instance) hands each half its own + // list. Two consequences, both wanted: the halves can select different takes (comping), and + // deleting a take from one leaves the other alone. + let (clip, left_src) = with_takes(&[10, 11, 12]); + let mut left = left_src.clone(); + let mut right = left_src.clone(); right.id = Uuid::new_v4(); + left.active_take = Some(0); right.active_take = Some(2); + assert_eq!(left.resolved_audio_pool_index(&clip), Some(10)); + assert_eq!(right.resolved_audio_pool_index(&clip), Some(12)); - assert_eq!(clip.resolved_audio_pool_index(left.active_take), Some(10)); - assert_eq!(clip.resolved_audio_pool_index(right.active_take), Some(12)); + // Delete take 2 (pool 11) from the left half only. + left.takes.remove(1); + assert_eq!(left.takes.len(), 2); + assert_eq!(right.takes.len(), 3, "the other half keeps its own takes"); + assert_eq!( + right.resolved_audio_pool_index(&clip), + Some(12), + "and its selection still points where it did", + ); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index a7b8d67..72bde66 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -944,31 +944,27 @@ impl Document { /// would break the uniform-take invariant comping depends on. /// /// `exclude` is the in-progress recording's own instance, which is on the layer but isn't a - /// candidate. Returns (instance id, clip id). + /// candidate. Returns the instance id. pub fn take_folder_at( &self, layer_id: &Uuid, loop_start: Beats, loop_len: Beats, exclude: &Uuid, - ) -> Option<(Uuid, Uuid)> { + ) -> Option { let Some(AnyLayer::Audio(audio_layer)) = self.get_layer(layer_id) else { return None; }; const EPS: f64 = 1e-6; audio_layer.clip_instances.iter().find_map(|ci| { - if ci.id == *exclude || (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS { + if ci.id == *exclude + || ci.takes.is_empty() + || (ci.timeline_start - loop_start).beats_to_f64().abs() > EPS + { return None; } - let clip = self.audio_clips.get(&ci.clip_id)?; - match clip.clip_type { - crate::clip::AudioClipType::TakeFolder { recorded_loop_beats, .. } - if (recorded_loop_beats - loop_len).beats_to_f64().abs() < EPS => - { - Some((ci.id, ci.clip_id)) - } - _ => None, - } + let recorded = ci.recorded_loop_beats?; + ((recorded - loop_len).beats_to_f64().abs() < EPS).then_some(ci.id) }) } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index f9bfe5c..5a4aa5a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1112,6 +1112,11 @@ struct PendingTakeAppend { /// The throwaway clip + instance the recording itself was captured into. recording_instance_id: Uuid, recording_clip_id: Uuid, + /// The recording's backend clip, which has to be torn down or it keeps playing alongside the + /// take folder we're appending to. Carried explicitly rather than looked up in + /// `clip_instance_to_backend_map`, because on the audio path the recording instance isn't in + /// that map yet — it's only added after promotion, which the append path skips. + recording_backend_id: Option, loop_start: Beats, loop_len: Beats, takes: Vec, @@ -2069,11 +2074,12 @@ impl EditorApp { layer_id: uuid::Uuid, recording_instance_id: uuid::Uuid, recording_clip_id: uuid::Uuid, + recording_backend_id: Option, loop_start: Beats, loop_len: Beats, takes: Vec, ) -> bool { - let Some((target_instance_id, target_clip_id)) = self.action_executor.document().take_folder_at( + let Some(target_instance_id) = self.action_executor.document().take_folder_at( &layer_id, loop_start, loop_len, @@ -2082,9 +2088,12 @@ impl EditorApp { return false; }; - // Drop the recording's backend clip; the target instance's own clip gets repointed at the - // new active take by the action's backend sync. - let backend_id = self.clip_instance_to_backend_map.remove(&recording_instance_id); + // Tear down the recording's own backend clip. Without this it keeps playing on top of the + // take folder we're appending to — two takes sounding at once. The target instance's clip is + // separately repointed at the new active take by the action's backend sync below. + let backend_id = recording_backend_id + .or_else(|| self.clip_instance_to_backend_map.remove(&recording_instance_id)); + self.clip_instance_to_backend_map.remove(&recording_instance_id); let track_id = self.layer_to_track_map.get(&layer_id).copied(); if let (Some(backend_id), Some(track_id), Some(controller_arc)) = (backend_id, track_id, self.audio_controller.as_ref()) @@ -2112,7 +2121,6 @@ impl EditorApp { let action = lightningbeam_core::actions::AppendTakesAction::new( layer_id, target_instance_id, - target_clip_id, takes, ); @@ -6692,6 +6700,13 @@ impl eframe::App for EditorApp { layer_id, recording_instance_id: instance_id, recording_clip_id: clip_id, + // The engine's recording clip. It isn't in + // clip_instance_to_backend_map yet (that only happens on + // promotion, which we're skipping), so hand it over + // directly or it'll keep sounding alongside the folder. + recording_backend_id: Some( + lightningbeam_core::action::BackendClipInstanceId::Audio(backend_clip_id), + ), loop_start, loop_len: loop_len_beats, takes: new_takes, @@ -6701,22 +6716,18 @@ impl eframe::App for EditorApp { continue; } - // Promote the in-progress recording clip to a take folder. + // Finalize the recording clip, and hang the takes off the INSTANCE. { let doc = self.action_executor.document_mut(); if let Some(clip) = doc.audio_clips.get_mut(&clip_id) { - clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { - takes: takes.iter().enumerate().map(|(i, &(pool_index, _))| { - lightningbeam_core::clip::AudioTake { - name: format!("Take {}", i + 1), - content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, - } - }).collect(), - recorded_loop_beats: loop_len_beats, - }; - // Audio takes are seconds-domain, and every take is - // exactly one cycle region long. - clip.set_content_duration(ClipDuration::Seconds(loop_len_seconds)); + // The clip's own content is take 1; the instance's take + // list overrides it with whichever take is active. Every + // take is exactly one cycle region long, so the clip's + // duration is the region. + clip.finalize_recording( + takes[0].0, + loop_len_seconds.seconds_to_f64(), + ); clip.name = format!("Cycle recording ({} takes)", takes.len()); } @@ -6735,7 +6746,14 @@ impl eframe::App for EditorApp { inst.trim_start = daw_backend::ContentTime::ZERO; inst.trim_end = Some(daw_backend::ContentTime(loop_len_seconds.seconds_to_f64())); + inst.takes = takes.iter().enumerate().map(|(i, &(pool_index, _))| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Audio { audio_pool_index: pool_index }, + } + }).collect(); inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); } } } @@ -7029,6 +7047,9 @@ impl eframe::App for EditorApp { layer_id, recording_instance_id: rec_inst, recording_clip_id: doc_clip_id, + // The MIDI path maps its recording instance during + // MidiRecordingProgress, so the map has it. + recording_backend_id: None, loop_start, loop_len: loop_len_beats, takes: new_takes, @@ -7046,17 +7067,10 @@ impl eframe::App for EditorApp { { let doc = self.action_executor.document_mut(); if let Some(clip) = doc.audio_clips.get_mut(&doc_clip_id) { - clip.clip_type = lightningbeam_core::clip::AudioClipType::TakeFolder { - takes: clip_ids.iter().enumerate().map(|(i, &mid)| { - lightningbeam_core::clip::AudioTake { - name: format!("Take {}", i + 1), - content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, - } - }).collect(), - recorded_loop_beats: loop_len_beats, - }; - // MIDI takes are beats-domain, and every take spans exactly - // one cycle region. + // The clip's own content stays take 1 (the clip the + // recording started on); the instance's take list + // overrides it with whichever take is active. MIDI takes + // are beats-domain and each spans one cycle region. clip.set_content_duration(ClipDuration::Beats(loop_len_beats)); clip.name = format!("Cycle recording ({} takes)", clip_ids.len()); } @@ -7071,7 +7085,14 @@ impl eframe::App for EditorApp { inst.timeline_duration = None; inst.trim_start = daw_backend::ContentTime::ZERO; inst.trim_end = Some(daw_backend::ContentTime(loop_len_beats.beats_to_f64())); + inst.takes = clip_ids.iter().enumerate().map(|(i, &mid)| { + lightningbeam_core::clip::AudioTake { + name: format!("Take {}", i + 1), + content: lightningbeam_core::clip::TakeContent::Midi { midi_clip_id: mid }, + } + }).collect(); inst.active_take = Some(last_take); + inst.recorded_loop_beats = Some(loop_len_beats); } } } @@ -7263,6 +7284,7 @@ impl eframe::App for EditorApp { req.layer_id, req.recording_instance_id, req.recording_clip_id, + req.recording_backend_id, req.loop_start, req.loop_len, req.takes, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index f1b0e21..7132ce8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -43,6 +43,7 @@ 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 TRASH: &str = "\u{e18d}"; // delete a take 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/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 5cfc40c..3506beb 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -7,17 +7,7 @@ //! - Image Assets (static images) use eframe::egui; -use lightningbeam_core::clip::{AudioClip, ResolvedContent, VectorClip}; - -/// Library label for an audio clip: a take folder advertises how many takes it holds, anything else -/// just names its kind. The library lists *clips*, not placements, so there's no active take here — -/// a folder is previewed by its first take. -fn take_label(clip: &AudioClip, kind: &str) -> String { - match clip.takes() { - Some(takes) => format!("{} ({} takes)", kind, takes.len()), - None => kind.to_string(), - } -} +use lightningbeam_core::clip::{ResolvedContent, VectorClip}; use lightningbeam_core::document::Document; use lightningbeam_core::layer::AnyLayer; use std::collections::{HashMap, HashSet}; @@ -928,9 +918,9 @@ impl AssetLibraryPane { continue; } - let (extra_info, drag_clip_type) = match &clip.resolve(None) { - ResolvedContent::Audio { .. } => (take_label(clip, "Sampled"), DragClipType::AudioSampled), - ResolvedContent::Midi { .. } => (take_label(clip, "MIDI"), DragClipType::AudioMidi), + let (extra_info, drag_clip_type) = match &clip.resolve() { + ResolvedContent::Audio { .. } => ("Sampled".to_string(), DragClipType::AudioSampled), + ResolvedContent::Midi { .. } => ("MIDI".to_string(), DragClipType::AudioMidi), ResolvedContent::Recording => { // Skip recording-in-progress clips (and empty take folders) from asset library continue; @@ -1128,12 +1118,12 @@ impl AssetLibraryPane { for (id, clip) in &document.audio_clips { if !linked_audio_ids.contains(id) && clip.folder_id == current_folder { - let (extra_info, drag_clip_type) = match &clip.resolve(None) { + let (extra_info, drag_clip_type) = match &clip.resolve() { ResolvedContent::Audio { .. } => { - (take_label(clip, "Sampled"), DragClipType::AudioSampled) + ("Sampled".to_string(), DragClipType::AudioSampled) } ResolvedContent::Midi { .. } => { - (take_label(clip, "MIDI"), DragClipType::AudioMidi) + ("MIDI".to_string(), DragClipType::AudioMidi) } ResolvedContent::Recording => { // Skip recording-in-progress clips (and empty take folders) @@ -1775,7 +1765,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)) } else { @@ -1800,7 +1790,7 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.resolve(None) { + match &clip.resolve() { ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { @@ -2354,7 +2344,7 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.resolve(None) { + match &clip.resolve() { ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) @@ -2491,7 +2481,7 @@ impl AssetLibraryPane { AssetCategory::Audio => { if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.resolve(None) { + match &clip.resolve() { ResolvedContent::Audio { audio_pool_index } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) @@ -2812,7 +2802,7 @@ impl AssetLibraryPane { let prefetched_waveform: Option> = if asset_category == AssetCategory::Audio && !self.thumbnail_cache.has(&asset_id) { if let Some(clip) = document.audio_clips.get(&asset_id) { - if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { let waveform: Option> = shared.raw_audio_cache.get(audio_pool_index) .map(|raw| peaks_from_raw_audio(raw, THUMBNAIL_SIZE as usize)); if waveform.is_some() { @@ -2852,7 +2842,7 @@ impl AssetLibraryPane { // Check if it's sampled or MIDI if let Some(clip) = document.audio_clips.get(&asset_id) { let bg_color = egui::Color32::from_rgba_unmultiplied(40, 40, 40, 200); - match &clip.resolve(None) { + match &clip.resolve() { ResolvedContent::Audio { .. } => { let wave_color = egui::Color32::from_rgb(100, 200, 100); if let Some(ref peaks) = prefetched_waveform { @@ -3197,7 +3187,7 @@ impl PaneRenderer for AssetLibraryPane { println!("🎨 [ASSET_LIB] Checking for thumbnails to invalidate (pools: {:?})", shared.audio_pools_with_new_waveforms); let mut invalidated_any = false; for (asset_id, clip) in &document_arc.audio_clips { - if let Some(audio_pool_index) = clip.resolved_audio_pool_index(None).as_ref() { + if let Some(audio_pool_index) = clip.audio_pool_index().as_ref() { if shared.audio_pools_with_new_waveforms.contains(audio_pool_index) { println!("❌ [ASSET_LIB] Invalidating thumbnail for asset {} (pool {})", asset_id, audio_pool_index); self.thumbnail_cache.invalidate(asset_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 0df4edf..e894887 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1807,16 +1807,10 @@ impl InfopanelPane { ui.label("Name:"); ui.label(&clip.name); }); - let take_count = clip.takes().map(|t| t.len()).unwrap_or(0); - let take_folder_label; let type_name = match &clip.clip_type { lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)", lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)", lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)", - lightningbeam_core::clip::AudioClipType::TakeFolder { .. } => { - take_folder_label = format!("Audio ({} takes)", take_count); - &take_folder_label - } }; ui.horizontal(|ui| { ui.label("Type:"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index 5586901..7a81053 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -467,7 +467,7 @@ impl PianoRollPane { if let Some(clip) = document.audio_clips.get(&instance.clip_id) { // Resolve through the instance's active take, so a MIDI take folder edits // whichever take it's actually playing. - if let Some(midi_clip_id) = clip.resolved_midi_clip_id(instance.active_take) { + if let Some(midi_clip_id) = instance.resolved_midi_clip_id(clip) { let duration = instance.effective_duration(clip.content_duration(), document.tempo_map()); // A MIDI clip's content time IS beats, which is what the piano roll's // x-axis uses. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 798180c..15d08b2 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -298,6 +298,9 @@ pub struct TimelinePane { take_badge_hits: Vec<(egui::Rect, uuid::Uuid, uuid::Uuid, usize, usize)>, /// The take-folder instance whose take menu is open, if any. open_take_menu: Option<(uuid::Uuid, uuid::Uuid)>, + /// The (instance, take index) being renamed inline in the take menu. + renaming_take: Option<(uuid::Uuid, usize)>, + take_rename_buffer: String, /// Seconds between the cycle region's start and where the current recording actually began. /// Zero unless the user punched in mid-region. Used to line the live waveform preview up with /// the region on each pass. @@ -741,6 +744,8 @@ impl TimelinePane { keyframe_diamond_hits: Vec::new(), take_badge_hits: Vec::new(), open_take_menu: None, + renaming_take: None, + take_rename_buffer: String::new(), cycle_record_lead_secs: 0.0, duration: 10.0, // Default 10 seconds is_scrubbing: false, @@ -1148,6 +1153,28 @@ impl TimelinePane { // The backend records in the beats domain; start_time is the seconds playhead. let start_beats = shared.action_executor.document().tempo_map().seconds_to_beats(Seconds(start_time)); + // Does this layer already hold takes over the cycle region? If so this recording is another + // take, however short it runs — without this, stopping before the loop came round would land + // it as a separate overlapping clip instead of joining the take list. The engine can't work + // this out for itself: whether takes exist is document state. + let force_takes: std::collections::HashMap = { + let doc = shared.action_executor.document(); + let region = match (doc.cycle_enabled, doc.cycle_region) { + (true, Some((ls, le))) if le > ls => Some((ls, le - ls)), + _ => None, + }; + candidates + .iter() + .map(|&(layer_id, _, _)| { + let forced = region.is_some_and(|(loop_start, loop_len)| { + doc.take_folder_at(&layer_id, loop_start, loop_len, &uuid::Uuid::nil()) + .is_some() + }); + (layer_id, forced) + }) + .collect() + }; + // Step 4: Dispatch recording for each candidate for &(layer_id, ref cat, _) in &candidates { match cat { @@ -1171,7 +1198,7 @@ impl TimelinePane { } if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); - controller.start_recording(track_id, start_beats); + controller.start_recording(track_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false)); println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time); } shared.recording_layer_ids.push(layer_id); @@ -1184,7 +1211,7 @@ impl TimelinePane { if let Some(controller_arc) = shared.audio_controller { let mut controller = controller_arc.lock().unwrap(); let clip_id = controller.create_midi_clip(track_id, start_beats, Beats::ZERO); - controller.start_midi_recording(track_id, clip_id, start_beats); + controller.start_midi_recording(track_id, clip_id, start_beats, force_takes.get(&layer_id).copied().unwrap_or(false)); shared.recording_clips.insert(layer_id, clip_id); println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}", track_id, start_time, clip_id); @@ -1695,9 +1722,7 @@ impl TimelinePane { return; }; - // The instance's *stored* selection, which is what rollback must restore — not `active`, - // which is that value clamped for display. - let old_take = document + let Some(instance) = document .get_layer(&layer_id) .and_then(|l| match l { lightningbeam_core::layer::AnyLayer::Audio(al) => { @@ -1705,7 +1730,14 @@ impl TimelinePane { } _ => None, }) - .and_then(|ci| ci.active_take); + else { + self.open_take_menu = None; + return; + }; + // The instance's *stored* selection, which is what rollback must restore — not `active`, + // which is that value clamped for display. + let old_take = instance.active_take; + let take_names: Vec = instance.takes.iter().map(|t| t.name.clone()).collect(); let mut close = false; let area = egui::Area::new(ui.id().with(("take_menu", instance_id))) @@ -1715,22 +1747,53 @@ impl TimelinePane { egui::Frame::popup(ui.style()).show(ui, |ui| { for i in 0..count { let is_active = i == active; - if ui - .selectable_label(is_active, format!("Take {}", i + 1)) - .clicked() - { - if !is_active { - pending_actions.push(Box::new( - lightningbeam_core::actions::SetActiveTakeAction::new( - layer_id, - instance_id, - i, - old_take, - ), - )); + let renaming = self.renaming_take == Some((instance_id, i)); + + ui.horizontal(|ui| { + if renaming { + let edit = ui.add( + egui::TextEdit::singleline(&mut self.take_rename_buffer) + .desired_width(110.0), + ); + edit.request_focus(); + // Commit on Enter or on clicking away; Escape abandons. + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if enter || edit.lost_focus() && !escape { + let name = self.take_rename_buffer.trim().to_string(); + if !name.is_empty() && name != take_names[i] { + pending_actions.push(Box::new( + lightningbeam_core::actions::RenameTakeAction::new( + layer_id, instance_id, i, name, + ), + )); + } + self.renaming_take = None; + } else if escape { + self.renaming_take = None; + } + return; } - close = true; - } + + let label = ui.selectable_label(is_active, &take_names[i]); + if label.clicked() { + if !is_active { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetActiveTakeAction::new( + layer_id, instance_id, i, old_take, + ), + )); + } + close = true; + } + // Double-click a take to rename it in place. Deleting lives on the clip's + // right-click menu, not here — a trash icon per row is a small target + // sitting right next to the one you actually meant to click. + if label.double_clicked() { + self.renaming_take = Some((instance_id, i)); + self.take_rename_buffer = take_names[i].clone(); + } + }); } }); }); @@ -1743,6 +1806,7 @@ impl TimelinePane { } if close { self.open_take_menu = None; + self.renaming_take = None; } } @@ -3971,7 +4035,7 @@ impl TimelinePane { if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) { // Resolve through the instance's active take, so a take folder draws // whichever take it actually plays. - match &clip.resolve(clip_instance.active_take) { + match &clip_instance.resolve(clip) { // MIDI: Draw piano roll (with loop iterations) lightningbeam_core::clip::ResolvedContent::Midi { midi_clip_id } => { if let Some(events) = midi_event_cache.get(midi_clip_id) { @@ -4357,12 +4421,10 @@ impl TimelinePane { // only. Records a hit rect so the click that opens the take menu can be // dispatched after rendering (the usual two-phase pattern), rather than // mutating the document mid-paint. - if let Some(take_count) = document - .get_audio_clip(&clip_instance.clip_id) - .and_then(|c| c.takes().map(|t| t.len())) - .filter(|n| *n > 0) - { - let active = clip_instance.active_take.unwrap_or(0).min(take_count - 1); + // Only worth showing when there's actually a choice to make. + if clip_instance.takes.len() > 1 { + let take_count = clip_instance.takes.len(); + let active = clip_instance.active_take_index(); let label = format!("Take {}/{}", active + 1, take_count); let text_color = theme.text_color( &["#timeline", ".take-badge"], @@ -6596,6 +6658,22 @@ impl PaneRenderer for TimelinePane { enabled }; + // Take management for the clip that was right-clicked. Only offered when there's more + // than one take — with a single take there's nothing to choose between, and "delete the + // only take" would leave the instance with nothing to play. + let take_target: Option<(uuid::Uuid, uuid::Uuid, usize, String)> = ctx_clip_id.and_then(|instance_id| { + let context_layers = document.context_layers(shared.editing_clip_id.as_ref()); + for (layer, instances) in all_layer_clip_instances(&context_layers) { + if let Some(ci) = instances.iter().find(|ci| ci.id == instance_id) { + if ci.takes.len() > 1 { + let active = ci.active_take_index(); + return Some((layer.id(), instance_id, active, ci.takes[active].name.clone())); + } + } + } + None + }); + let area_id = ui.id().with("clip_context_menu"); let mut item_clicked = false; let area_response = egui::Area::new(area_id) @@ -6661,6 +6739,34 @@ impl PaneRenderer for TimelinePane { shared.pending_menu_actions.push(crate::menu::MenuAction::Delete); item_clicked = true; } + + // Take management, on the clip that was right-clicked. Takes live on the + // INSTANCE, so on a comped split this prunes the half you clicked and leaves + // the other half's alternatives alone. + if let Some((take_layer_id, take_instance_id, active_index, active_name)) = &take_target { + ui.separator(); + // Deletes the take that's PLAYING — the one the badge is showing — so + // it's named rather than just "Delete Take". + if menu_item(ui, &format!("Delete \"{}\"", active_name), true) { + shared.pending_actions.push(Box::new( + lightningbeam_core::actions::DeleteTakeAction::new( + *take_layer_id, + *take_instance_id, + *active_index, + ), + )); + item_clicked = true; + } + if menu_item(ui, "Delete Unused Takes", true) { + shared.pending_actions.push(Box::new( + lightningbeam_core::actions::DeleteUnusedTakesAction::new( + *take_layer_id, + *take_instance_id, + ), + )); + item_clicked = true; + } + } }); }); From 8cde113797c1cb525ddf4602c53b2dd52a508795 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 13 Jul 2026 02:13:06 -0400 Subject: [PATCH 09/11] Cycle recording phase 1: transport loop region Adds a cycle (loop) region: a range on the timeline ruler that the transport wraps at during playback. This is the substrate for GarageBand-style multi-take cycle recording (phases 2 and 3). The region is authored in BEATS so it stays put musically across tempo changes. It lives on the Document (saved in the .beam, serde-defaulted so old files load) and is edited through SetCycleRegionAction, so it is undoable and marks the document modified like any other edit. Backend: - Engine gains loop_region/loop_enabled plus a wrap at the single playhead-advance point in process(). The wrap is phase-preserving (modulo, so an overshoot larger than the loop can't strand the playhead outside the region) and gated on playhead >= 0 so a count-in pre-roll never wraps. - Sounding voices are deliberately NOT reset at the wrap the way Command::Seek does, since that would chop sustain and reverb tails at every pass. - MidiRecordingState::wrap_at_cycle writes note-offs for held notes at the region end and re-opens them at the region start, so a key held across the boundary can't end up with a negative duration or hang. - loop_bounds_frozen freezes the region's sample bounds for the duration of an *audio* recording. Phrased positively on audio so future multi-track recording inherits it: MIDI is beat-segmented and tempo-invariant, but audio is segmented geometrically and we have no time-stretch, so cross-tempo audio takes wouldn't be compable anyway. - Command::Play jumps to loop_start when starting from outside the region; starting inside it plays from where you are. Editor: - Cycle lane along the bottom of the ruler (bottom, so it doesn't cover the bar numbers), painted inside render_ruler under the ticks. Only exists while looping is armed; with cycle off the ruler is entirely the playhead scrubber, as before. - Drag to create/move/resize with a three-zone hit test, previewed locally and committed as ONE action on release. Driven off raw pointer state rather than an egui Response: the lane sits inside the timeline's content response, and a second widget on the same pixels just contests hover every frame. - snap_to_grid/quantize_grid_size take a min_grid_px "visual coarseness" parameter instead of a hardcoded constant, with two named profiles: SNAP_PX_FINE for the playhead and clip edges, SNAP_PX_CYCLE (coarser) for the cycle region, so loops land on bars rather than odd subdivisions. - Cycle toggle button using the Lucide repeat glyph. Cargo.lock picks up the 1.0.9-alpha version bump it missed. --- daw-backend/src/audio/engine.rs | 141 +++++++ daw-backend/src/audio/recording.rs | 23 ++ daw-backend/src/command/types.rs | 7 + lightningbeam-ui/Cargo.lock | 2 +- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/set_cycle_region.rs | 93 +++++ .../lightningbeam-core/src/document.rs | 13 + .../lightningbeam-editor/src/main.rs | 16 + .../lightningbeam-editor/src/mobile/icons.rs | 1 + .../src/panes/timeline.rs | 369 ++++++++++++++++-- 10 files changed, 643 insertions(+), 24 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_cycle_region.rs 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)); From 5e73f7f75df75ae830efcb8faacb68393d279320 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 13:04:25 -0400 Subject: [PATCH 10/11] Painting UX: unified color/brush tools, tablet buttons, layer opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a batch of issues found during a painting test. Tools - Paint bucket used the background color unconditionally. It now picks FG/BG like the brush does, via a shared Color: [FG][BG] row that every color-using tool renders (brush, bucket, eyedropper). - The eyedropper previously inferred its target swatch from active_color_mode, which was only ever set as a side effect of opening a color picker — so where a sample landed depended on invisible state. It now has an explicit toggle, and active_color_mode is gone. - Effect brushes (dodge/burn, sponge, blur, smudge, clone, heal, pattern) were stuck on a hardcoded Gaussian dab. Replaced ~20 flat per-tool fields with a BrushKind -> BrushSlot model: every dab-painting tool now owns its size/strength/hardness/spacing, its .myb preset, and its FG/BG choice, and gets the full brush library. base_settings already flowed into the dab engine independently of blend_mode, so this needed no core or WGSL changes. Cursors - Rebuilt around three kinds instead of "icon = cursor": System (real OS cursor for Select/Transform), Precise (glyph with a real tip, e.g. pencil/pipette), and Badge (crosshair marks the click point, glyph sits to its bottom-right) for icons with no focus point. - Cursors are now OS-composited via egui::CursorImage rather than painted into the scene, so they no longer lag the pointer. Glyphs are rasterized from egui's font atlas using epaint's own placement maths, so they land where painter.text put them. Drops the SVG cursor rasterization path. Tablet - The Wayland backend never handled zwp_tablet_tool_v2 Button events, so stylus barrel buttons were silently dropped. Both Wayland and X11 now emit them, with actions bound in Preferences (Pan/Eyedropper/Eraser). A pan-bound button suppresses the tool the way Alt+drag does, since the pen tip is still down while panning. Also adds middle-mouse panning. Timeline - Raster layers had no opacity control; the slider always drove volume, which is meaningless on them. The layer row is now three tiers, and each layer only shows controls that apply to it: Vector/Video get both volume and opacity (they can hold movie clips with audio), Raster/Text get opacity, Audio gets volume. Mute/solo are hidden where there's no audio. - Layer toggles use Lucide icons with tooltips, and gain a visibility (eye) toggle. LayerProperty::Visible already existed but was only reachable from the info panel. - Below 1.5x the layer-header width, the track area is dropped and the headers take the full pane. Panes - Color picker popups closed on any click (including on their own hue slider) and would not close on a drag — so drawing on the stage left one open. Shared color_swatch widget closes on a press outside instead. - Stage header gains undo/redo buttons for tablet use, and taller targets. - Toolbar is real egui layout (horizontal_wrapped) instead of absolute rect math, so it scrolls when the pane is too short. - Tools with no SVG icon use Lucide glyphs instead of a TODO placeholder. --- .../lightningbeam-editor/src/config.rs | 46 ++ .../lightningbeam-editor/src/custom_cursor.rs | 536 ++++++++++------ .../lightningbeam-editor/src/main.rs | 11 +- .../lightningbeam-editor/src/mobile/icons.rs | 57 ++ .../src/panes/gradient_editor.rs | 42 +- .../src/panes/infopanel.rs | 144 ++--- .../lightningbeam-editor/src/panes/mod.rs | 2 - .../lightningbeam-editor/src/panes/stage.rs | 179 +++++- .../src/panes/timeline.rs | 306 ++++++--- .../lightningbeam-editor/src/panes/toolbar.rs | 597 +++++++++--------- .../src/preferences/dialog.rs | 53 +- .../lightningbeam-editor/src/tablet.rs | 187 +++++- .../src/tools/blur_sharpen.rs | 36 +- .../src/tools/clone_stamp.rs | 12 +- .../src/tools/dodge_burn.rs | 36 +- .../lightningbeam-editor/src/tools/erase.rs | 17 +- .../src/tools/healing_brush.rs | 12 +- .../lightningbeam-editor/src/tools/mod.rs | 241 ++++--- .../lightningbeam-editor/src/tools/paint.rs | 19 +- .../src/tools/pattern_stamp.rs | 15 +- .../lightningbeam-editor/src/tools/smudge.rs | 45 +- .../lightningbeam-editor/src/tools/sponge.rs | 36 +- .../src/widgets/color_swatch.rs | 102 +++ .../lightningbeam-editor/src/widgets/mod.rs | 1 + 24 files changed, 1735 insertions(+), 997 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a724288..1801a9b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -78,6 +78,47 @@ pub struct AppConfig { /// Last-used audio-export "Album" tag, remembered so it prefills next time. #[serde(default)] pub last_audio_album: String, + + /// What the stylus's lower barrel button does while held. + #[serde(default = "defaults::tablet_button_lower")] + pub tablet_button_lower: TabletButtonAction, + + /// What the stylus's upper barrel button does while held. + #[serde(default = "defaults::tablet_button_upper")] + pub tablet_button_upper: TabletButtonAction, +} + +/// What a stylus barrel button does while held down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TabletButtonAction { + /// Do nothing. + None, + /// Drag to pan the stage. + #[default] + Pan, + /// Temporarily switch to the eyedropper. + Eyedropper, + /// Temporarily switch to the eraser. + Erase, +} + +impl TabletButtonAction { + pub const ALL: [TabletButtonAction; 4] = [ + TabletButtonAction::None, + TabletButtonAction::Pan, + TabletButtonAction::Eyedropper, + TabletButtonAction::Erase, + ]; + + pub fn label(self) -> &'static str { + match self { + TabletButtonAction::None => "None", + TabletButtonAction::Pan => "Pan", + TabletButtonAction::Eyedropper => "Eyedropper", + TabletButtonAction::Erase => "Eraser", + } + } } impl Default for AppConfig { @@ -100,6 +141,8 @@ impl Default for AppConfig { waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), last_audio_artist: String::new(), last_audio_album: String::new(), + tablet_button_lower: defaults::tablet_button_lower(), + tablet_button_upper: defaults::tablet_button_upper(), } } } @@ -290,6 +333,9 @@ impl AppConfig { /// Default values for preferences (matches JS implementation) mod defaults { + use super::TabletButtonAction; + pub fn tablet_button_lower() -> TabletButtonAction { TabletButtonAction::Pan } + pub fn tablet_button_upper() -> TabletButtonAction { TabletButtonAction::Eyedropper } pub fn bpm() -> u32 { 120 } pub fn framerate() -> u32 { 24 } pub fn file_width() -> u32 { 800 } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index c4ae419..1acc9de 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -1,205 +1,380 @@ //! Custom cursor system //! -//! Provides SVG-based custom cursors beyond egui's built-in system cursors. -//! When a custom cursor is active, the system cursor is hidden and the SVG -//! cursor image is drawn at the pointer position. +//! Tool cursors are drawn from the bundled Lucide icon font. A tool falls into one of three +//! kinds, because "draw the tool's icon at the pointer" only works for icons that actually have +//! a point: +//! +//! * [`CursorKind::System`] — the OS cursor already says it better than we can (the arrow for +//! Select, the move cross for Transform). We use the real OS cursor. +//! * [`CursorKind::Precise`] — the glyph has an unambiguous tip (pencil, brush, pipette), so it +//! *is* the cursor: the tip lands exactly on the click point. +//! * [`CursorKind::Badge`] — the glyph has no focus point (stamp, bandage, spray can). Using it +//! alone would leave you guessing where you're clicking, so we pair it with a crosshair: the +//! crosshair marks the click point and the glyph hangs off its bottom-right. +//! +//! Rather than *painting* the cursor into the egui scene, we rasterize it and hand it to the +//! windowing system as a real OS cursor (`egui::CursorImage` → winit `CustomCursor`). A painted +//! cursor is composited with our frame and therefore always trails the pointer by a frame or +//! more; an OS cursor is composited by the window system and doesn't lag at all. +//! +//! The glyphs are rasterized out of egui's own font atlas using the same placement maths as +//! `epaint`'s text tessellator, so a glyph lands exactly where `painter.text` would have put it. use eframe::egui; -use egui::TextureHandle; use lightningbeam_core::tool::Tool; use std::collections::HashMap; +use std::sync::Arc; -/// Custom cursor identifiers +use crate::mobile::icons; + +/// What kind of cursor a tool gets. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CursorKind { + /// Use a native OS cursor; build nothing ourselves. + System(egui::CursorIcon), + /// The glyph is the cursor. `hotspot` is the click point in Lucide's 24×24 icon grid. + Precise { + glyph: &'static str, + hotspot: egui::Vec2, + }, + /// A crosshair marks the click point; the glyph sits to its bottom-right. + Badge { glyph: &'static str }, +} + +/// Hotspot for a glyph whose tip is at the bottom-left (pencil, brush, pen, pipette). +const TIP_BOTTOM_LEFT: egui::Vec2 = egui::vec2(3.0, 21.0); +/// Hotspot for a glyph centred on the click point. +const TIP_CENTER: egui::Vec2 = egui::vec2(12.0, 12.0); + +/// Which cursor a stage tool uses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CustomCursor { - // Stage tool cursors - Select, - Draw, - Transform, - Rectangle, - Ellipse, - PaintBucket, - Eyedropper, - Line, - Polygon, - BezierEdit, - Text, - // Timeline cursors + Tool(Tool), + /// Timeline: dragging the end of the loop region. LoopExtend, } impl CustomCursor { - /// Convert a Tool enum to the corresponding custom cursor pub fn from_tool(tool: Tool) -> Self { + CustomCursor::Tool(tool) + } + + pub fn kind(&self) -> CursorKind { + use egui::CursorIcon as Sys; + + let tool = match self { + CustomCursor::LoopExtend => { + return CursorKind::Precise { + glyph: icons::REPEAT, + hotspot: TIP_CENTER, + } + } + CustomCursor::Tool(t) => *t, + }; + + let precise = |glyph, hotspot| CursorKind::Precise { glyph, hotspot }; + let badge = |glyph| CursorKind::Badge { glyph }; + match tool { - Tool::Select => CustomCursor::Select, - Tool::Draw => CustomCursor::Draw, - Tool::Transform => CustomCursor::Transform, - Tool::Rectangle => CustomCursor::Rectangle, - Tool::Ellipse => CustomCursor::Ellipse, - Tool::PaintBucket => CustomCursor::PaintBucket, - Tool::Eyedropper => CustomCursor::Eyedropper, - Tool::Line => CustomCursor::Line, - Tool::Polygon => CustomCursor::Polygon, - Tool::BezierEdit => CustomCursor::BezierEdit, - Tool::Text => CustomCursor::Text, - Tool::RegionSelect => CustomCursor::Select, - Tool::Split => CustomCursor::Select, - Tool::Erase => CustomCursor::Draw, - Tool::Smudge => CustomCursor::Draw, - Tool::SelectLasso => CustomCursor::Select, - // Raster brush tools — use draw cursor until implemented - Tool::Pencil - | Tool::Pen - | Tool::Airbrush - | Tool::CloneStamp - | Tool::HealingBrush - | Tool::PatternStamp - | Tool::DodgeBurn - | Tool::Sponge - | Tool::BlurSharpen => CustomCursor::Draw, - // Selection tools — use select cursor until implemented - Tool::SelectEllipse - | Tool::MagicWand - | Tool::QuickSelect => CustomCursor::Select, - // Other tools — use select cursor until implemented - Tool::Gradient - | Tool::CustomShape - | Tool::Warp - | Tool::Liquify => CustomCursor::Select, - } - } + // The OS knows these better than we do. + Tool::Select => CursorKind::System(Sys::Default), + Tool::Transform => CursorKind::System(Sys::Move), - /// Hotspot offset — the "click point" relative to the image top-left - pub fn hotspot(&self) -> egui::Vec2 { - match self { - // Select cursor: pointer tip at top-left - CustomCursor::Select => egui::vec2(3.0, 1.0), - // Drawing tools: tip at bottom-left - CustomCursor::Draw => egui::vec2(1.0, 23.0), - // Transform: center - CustomCursor::Transform => egui::vec2(12.0, 12.0), - // Shape tools: crosshair at center - CustomCursor::Rectangle - | CustomCursor::Ellipse - | CustomCursor::Line - | CustomCursor::Polygon => egui::vec2(12.0, 12.0), - // Paint bucket: tip at bottom-left - CustomCursor::PaintBucket => egui::vec2(2.0, 21.0), - // Eyedropper: tip at bottom - CustomCursor::Eyedropper => egui::vec2(4.0, 22.0), - // Bezier edit: tip at top-left - CustomCursor::BezierEdit => egui::vec2(3.0, 1.0), - // Text: I-beam center - CustomCursor::Text => egui::vec2(12.0, 12.0), - // Loop extend: center of circular arrow - CustomCursor::LoopExtend => egui::vec2(12.0, 12.0), - } - } + // Glyphs with a real tip: the icon is the cursor. + Tool::Draw => precise(icons::BRUSH, TIP_BOTTOM_LEFT), + Tool::Pencil => precise(icons::PENCIL, TIP_BOTTOM_LEFT), + Tool::Pen => precise(icons::PEN_TOOL, TIP_BOTTOM_LEFT), + Tool::Eyedropper => precise(icons::PIPETTE, TIP_BOTTOM_LEFT), + Tool::PaintBucket => precise(icons::PAINT_BUCKET, TIP_BOTTOM_LEFT), + Tool::Text => precise(icons::TEXT_CURSOR, TIP_CENTER), - /// Get the embedded SVG data for this cursor - fn svg_data(&self) -> &'static [u8] { - match self { - CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"), - CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"), - CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"), - CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"), - CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"), - CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"), - CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"), - CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"), - CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"), - CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"), - CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"), - CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"), + // Shape tools: crosshair for precision, glyph to say which shape. + Tool::Rectangle => badge(icons::SQUARE), + Tool::Ellipse => badge(icons::CIRCLE), + Tool::Line => badge(icons::MINUS), + Tool::Polygon => badge(icons::HEXAGON), + Tool::CustomShape => badge(icons::SHAPES), + + // Selection tools. + Tool::RegionSelect => badge(icons::SQUARE_DASHED), + Tool::SelectEllipse => badge(icons::CIRCLE_DASHED), + Tool::SelectLasso => badge(icons::LASSO_SELECT), + Tool::MagicWand => badge(icons::WAND_SPARKLES), + Tool::QuickSelect => badge(icons::BRUSH), + Tool::Split => badge(icons::SCISSORS), + + // Raster tools whose icons have no focus point. These also draw a brush-size ring on + // the stage, which is the real precision cue. + Tool::Erase => badge(icons::ERASER), + Tool::Airbrush => badge(icons::SPRAY_CAN), + Tool::Smudge => badge(icons::POINTER), + Tool::CloneStamp => badge(icons::STAMP), + Tool::PatternStamp => badge(icons::SHAPES), + Tool::HealingBrush => badge(icons::BANDAGE), + Tool::DodgeBurn => badge(icons::SUN_MOON), + Tool::Sponge => badge(icons::DROPLETS), + Tool::BlurSharpen => badge(icons::CONTRAST), + Tool::Gradient => badge(icons::BLEND), + Tool::Warp => badge(icons::SCALING), + Tool::Liquify => badge(icons::DROPLET), + + // Vertex editing: crosshair to place the point, glyph to say we're in bezier mode. + Tool::BezierEdit => badge(icons::SPLINE), } } } -/// Cache of rasterized cursor textures (black fill + white outline version) +// --------------------------------------------------------------------------- +// Geometry (logical points; scaled by pixels_per_point when rasterized) +// --------------------------------------------------------------------------- + +/// Rendered size of a cursor glyph. +const GLYPH_SIZE: f32 = 18.0; +/// Lucide icons are authored on a 24×24 grid; hotspots are given in those units. +const ICON_GRID: f32 = 24.0; +/// Where a badge glyph's top-left sits relative to the crosshair centre. +const BADGE_OFFSET: egui::Vec2 = egui::vec2(6.0, 6.0); +/// Half-length of a crosshair arm. +const CROSSHAIR_ARM: f32 = 7.0; +/// Gap between the crosshair centre and the start of each arm, so the click point stays visible. +const CROSSHAIR_GAP: f32 = 2.0; +/// Margin around the artwork, leaving room for the outline. +const MARGIN: f32 = 1.5; + +// --------------------------------------------------------------------------- +// Rasterization +// --------------------------------------------------------------------------- + +/// Cache of rasterized cursor images, keyed by cursor and DPI scale. +#[derive(Default)] pub struct CursorCache { - /// Black cursor for the main image - textures: HashMap, - /// White cursor for the outline - outline_textures: HashMap, + images: HashMap<(CustomCursor, u32), Option>, + next_id: u64, } impl CursorCache { pub fn new() -> Self { - Self { - textures: HashMap::new(), - outline_textures: HashMap::new(), + Self::default() + } + + fn get_or_build( + &mut self, + ctx: &egui::Context, + cursor: CustomCursor, + ppp: f32, + ) -> Option { + let key = (cursor, ppp.to_bits()); + if let Some(cached) = self.images.get(&key) { + return cached.clone(); } - } - /// Get or lazily load the black (fill) cursor texture - pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - let svg_with_color = svg_string.replace("currentColor", "#000000"); - rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG") - }) - } + let id = self.next_id; + self.next_id += 1; - /// Get or lazily load the white (outline) cursor texture - pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.outline_textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - // Replace all colors with white for the outline - let svg_white = svg_string - .replace("currentColor", "#ffffff") - .replace("#000000", "#ffffff") - .replace("#000", "#ffffff"); - rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG outline") - }) + let built = build_cursor_image(ctx, cursor.kind(), ppp, id); + self.images.insert(key, built.clone()); + built } } -const CURSOR_SIZE: u32 = 24; -const OUTLINE_OFFSET: f32 = 1.0; +/// An alpha coverage mask being composed, in physical pixels. +struct Mask { + w: usize, + h: usize, + a: Vec, +} -/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg) -fn rasterize_cursor_svg( - svg_data: &[u8], - name: &str, - render_size: u32, +impl Mask { + fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + + fn add(&mut self, x: usize, y: usize, coverage: f32) { + if x < self.w && y < self.h { + let p = &mut self.a[y * self.w + x]; + *p = (*p + coverage).min(1.0); + } + } + + fn get(&self, x: isize, y: isize) -> f32 { + if x < 0 || y < 0 || x as usize >= self.w || y as usize >= self.h { + 0.0 + } else { + self.a[y as usize * self.w + x as usize] + } + } + + /// Fill an axis-aligned rect (physical px, may be fractional) with full coverage. + fn fill_rect(&mut self, rect: egui::Rect) { + let x0 = rect.min.x.floor().max(0.0) as usize; + let y0 = rect.min.y.floor().max(0.0) as usize; + let x1 = (rect.max.x.ceil() as usize).min(self.w); + let y1 = (rect.max.y.ceil() as usize).min(self.h); + for y in y0..y1 { + for x in x0..x1 { + self.add(x, y, 1.0); + } + } + } +} + +/// Rasterize a cursor into a premultiplied-RGBA image: black artwork with a white outline, so it +/// reads against both light and dark backgrounds. +fn build_cursor_image( ctx: &egui::Context, -) -> Option { - let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; - let pixmap_size = tree.size().to_int_size(); - let scale_x = render_size as f32 / pixmap_size.width() as f32; - let scale_y = render_size as f32 / pixmap_size.height() as f32; - let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale_x, scale_y), - &mut pixmap.as_mut(), - ); - let rgba_data = pixmap.data().to_vec(); - let color_image = egui::ColorImage::from_rgba_unmultiplied( - [render_size as usize, render_size as usize], - &rgba_data, - ); - Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR)) + kind: CursorKind, + ppp: f32, + id: u64, +) -> Option { + let (glyph, glyph_origin, hotspot, crosshair) = match kind { + CursorKind::System(_) => return None, + CursorKind::Precise { glyph, hotspot } => { + let scale = GLYPH_SIZE / ICON_GRID; + ( + glyph, + egui::vec2(MARGIN, MARGIN), + egui::pos2(MARGIN, MARGIN) + hotspot * scale, + false, + ) + } + CursorKind::Badge { glyph } => { + // The crosshair centre is the click point; place it a margin + arm in from the corner. + let centre = egui::pos2(MARGIN + CROSSHAIR_ARM, MARGIN + CROSSHAIR_ARM); + (glyph, centre.to_vec2() + BADGE_OFFSET, centre, true) + } + }; + + // Lay the glyph out exactly as `painter.text(.., Align2::LEFT_TOP, ..)` would, so we inherit + // epaint's placement rather than re-deriving it from font metrics. + let galley = ctx.fonts_mut(|f| { + f.layout_no_wrap( + glyph.to_owned(), + icons::font(GLYPH_SIZE), + egui::Color32::WHITE, + ) + }); + + // Canvas must cover the glyph, and the crosshair too when there is one. + let mut content = egui::Rect::from_min_size(glyph_origin.to_pos2(), galley.size()); + if crosshair { + content = content.union(egui::Rect::from_center_size( + hotspot, + egui::Vec2::splat(2.0 * CROSSHAIR_ARM), + )); + } + let canvas = content.expand(MARGIN); + + let w = (canvas.width() * ppp).ceil() as usize; + let h = (canvas.height() * ppp).ceil() as usize; + if w == 0 || h == 0 || w > 2048 || h > 2048 { + return None; // winit caps cursors at 2048px. + } + + let mut mask = Mask::new(w, h); + // Everything is positioned relative to the canvas origin. + let to_px = |p: egui::Pos2| ((p - canvas.min.to_vec2()).to_vec2() * ppp).to_pos2(); + + // --- Crosshair --- + if crosshair { + let c = to_px(hotspot); + let thickness = ppp.max(1.0); // one physical pixel, at least + let arm = CROSSHAIR_ARM * ppp; + let gap = CROSSHAIR_GAP * ppp; + // Four arms, leaving a gap at the centre so the exact click point stays visible. + for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] { + let from = egui::pos2(c.x + dx * gap, c.y + dy * gap); + let to = egui::pos2(c.x + dx * arm, c.y + dy * arm); + let rect = egui::Rect::from_two_pos(from, to).expand2(if dx == 0.0 { + egui::vec2(thickness / 2.0, 0.0) + } else { + egui::vec2(0.0, thickness / 2.0) + }); + mask.fill_rect(rect); + } + } + + // --- Glyph, copied out of egui's font atlas --- + let atlas = ctx.fonts_mut(|f| f.image()); + let atlas_w = atlas.size[0]; + for row in &galley.rows { + for g in &row.glyphs { + let uv = g.uv_rect; + if uv.is_nothing() { + continue; + } + // Same maths as epaint's text tessellator: the glyph's top-left in points is + // `glyph.pos + uv_rect.offset`, relative to the galley's top-left. + let left_top = to_px((glyph_origin + (g.pos + uv.offset).to_vec2()).to_pos2()); + let src_w = (uv.max[0] - uv.min[0]) as usize; + let src_h = (uv.max[1] - uv.min[1]) as usize; + + for sy in 0..src_h { + for sx in 0..src_w { + let src_i = (uv.min[1] as usize + sy) * atlas_w + (uv.min[0] as usize + sx); + let Some(px) = atlas.pixels.get(src_i) else { continue }; + let coverage = px.a() as f32 / 255.0; + if coverage <= 0.0 { + continue; + } + let dx = (left_top.x.round() as isize) + sx as isize; + let dy = (left_top.y.round() as isize) + sy as isize; + if dx >= 0 && dy >= 0 { + mask.add(dx as usize, dy as usize, coverage); + } + } + } + } + } + + // --- Compose: black fill over a white outline (dilate the mask by one pixel) --- + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let fill = mask.get(x as isize, y as isize); + + let mut outline: f32 = 0.0; + for oy in -1..=1_isize { + for ox in -1..=1_isize { + outline = outline.max(mask.get(x as isize + ox, y as isize + oy)); + } + } + + // White outline underneath, black fill on top; premultiplied. + let white = outline * (1.0 - fill); + let alpha = fill + outline * (1.0 - fill); + let c = (white * 255.0).round().clamp(0.0, 255.0) as u8; + + let i = (y * w + x) * 4; + rgba[i] = c; + rgba[i + 1] = c; + rgba[i + 2] = c; + rgba[i + 3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8; + } + } + + let hot = to_px(hotspot); + Some(egui::CursorImage { + id, + rgba: Arc::new(rgba), + size: (w as u16, h as u16), + hotspot: ( + hot.x.round().clamp(0.0, w as f32 - 1.0) as u16, + hot.y.round().clamp(0.0, h as f32 - 1.0) as u16, + ), + }) } // --- Per-frame cursor slot using egui context data --- -/// Key for storing the active custom cursor in egui's per-frame data #[derive(Clone, Copy)] struct ActiveCustomCursor(CustomCursor); /// Set the custom cursor for this frame. Call from any pane during rendering. -/// This hides the system cursor and draws the SVG cursor at pointer position. pub fn set(ctx: &egui::Context, cursor: CustomCursor) { ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor))); } -/// Render the custom cursor overlay. Call at the end of the main update loop. +/// Hand the active cursor to the windowing system. Call at the end of the main update loop. pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { // Take and remove the cursor so it doesn't persist to the next frame let id = egui::Id::new("active_custom_cursor"); @@ -209,43 +384,24 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { val }); - if let Some(ActiveCustomCursor(cursor)) = cursor { - // If a system cursor was explicitly set (resize handles, text inputs, etc.), - // let it take priority over the custom cursor - let system_cursor = ctx.output(|o| o.cursor_icon); - if system_cursor != egui::CursorIcon::Default { - return; - } + let Some(ActiveCustomCursor(cursor)) = cursor else { return }; - // Hide the system cursor - ctx.set_cursor_icon(egui::CursorIcon::None); + // If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it + // win — it knows something about the hover target that we don't. + if ctx.output(|o| o.cursor_icon) != egui::CursorIcon::Default { + return; + } - if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) { - let hotspot = cursor.hotspot(); - let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - let painter = ctx.debug_painter(); - - // Draw white outline: render white version offset in 8 directions - let outline_tex = cache.get_or_load_outline(cursor, ctx); - let outline_id = outline_tex.id(); - for &(dx, dy) in &[ - (-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0), - (0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET), - (-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET), - (-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET), - ] { - let offset_rect = egui::Rect::from_min_size( - pos - hotspot + egui::vec2(dx, dy), - size, - ); - painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE); + match cursor.kind() { + CursorKind::System(icon) => ctx.set_cursor_icon(icon), + _ => { + let ppp = ctx.pixels_per_point(); + if let Some(image) = cache.get_or_build(ctx, cursor, ppp) { + ctx.set_cursor_image(Some(image)); + } else { + // Rasterization failed — better a crosshair than an invisible cursor. + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); } - - // Draw black fill on top - let fill_tex = cache.get_or_load(cursor, ctx); - let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size); - painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a2f1e88..2dcdfb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1136,7 +1136,6 @@ struct EditorApp { selected_tool: Tool, // Currently selected drawing tool fill_color: egui::Color32, // Fill color for drawing stroke_color: egui::Color32, // Stroke color for drawing - active_color_mode: panes::ColorMode, // Which color (fill/stroke) was last interacted with pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane @@ -1351,9 +1350,9 @@ struct EditorApp { effect_thumbnail_generator: Option, /// Custom cursor cache for SVG cursors - cursor_cache: custom_cursor::CursorCache, /// Cross-platform graphics tablet (pen/stylus) input state tablet: tablet::TabletInput, + cursor_cache: custom_cursor::CursorCache, /// Debug test mode (F5) — input recording, panic capture & visual replay #[cfg(debug_assertions)] test_mode: test_mode::TestModeState, @@ -1410,6 +1409,10 @@ impl EditorApp { // Load application config let mut config = AppConfig::load(); + + // Mirror the configured stylus barrel-button actions into the tablet layer. + tablet::set_button_actions(config.tablet_button_lower, config.tablet_button_upper); + // One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any // recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in // Recent or get auto-reopened below. @@ -1532,7 +1535,6 @@ impl EditorApp { selected_tool: Tool::Select, // Default tool fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke - active_color_mode: panes::ColorMode::default(), // Default to fill color pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, @@ -1645,8 +1647,8 @@ impl EditorApp { test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry), // Debug overlay (F3) - cursor_cache: custom_cursor::CursorCache::new(), tablet: tablet::TabletInput::new(cc), + cursor_cache: custom_cursor::CursorCache::new(), debug_overlay_visible: false, debug_stats_collector: debug_overlay::DebugStatsCollector::new(), gpu_info, @@ -7965,7 +7967,6 @@ impl EditorApp { selected_tool: &mut self.selected_tool, fill_color: &mut self.fill_color, stroke_color: &mut self.stroke_color, - active_color_mode: &mut self.active_color_mode, pending_view_action: &mut self.pending_view_action, fallback_pane_priority: &mut scratch.fallback_pane_priority, pending_handlers: &mut scratch.pending_handlers, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index f1b0e21..d15a71e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -82,3 +82,60 @@ pub const FILE_PLUS: &str = "\u{e0c9}"; pub const COPY: &str = "\u{e09e}"; pub const LAYERS: &str = "\u{e529}"; pub const ELLIPSIS: &str = "\u{e0b6}"; +// Undo / redo (stage header). +pub const UNDO_2: &str = "\u{e2a1}"; +pub const REDO_2: &str = "\u{e2a0}"; +// Tool cursors (see custom_cursor.rs). +pub const STAMP: &str = "\u{e3bb}"; +pub const SPRAY_CAN: &str = "\u{e495}"; +pub const BANDAGE: &str = "\u{e61d}"; +pub const DROPLET: &str = "\u{e0b4}"; +pub const TEXT_CURSOR: &str = "\u{e264}"; +pub const CROSSHAIR: &str = "\u{e0ac}"; +pub const POINTER: &str = "\u{e1e8}"; +pub const CONTRAST: &str = "\u{e09d}"; +pub const WIND: &str = "\u{e1b0}"; +pub const SUN_MOON: &str = "\u{e2b2}"; +pub const SPLINE: &str = "\u{e38b}"; +pub const DROPLETS: &str = "\u{e0b5}"; +pub const CIRCLE_DASHED: &str = "\u{e4b0}"; +pub const PALETTE: &str = "\u{e1dd}"; +pub const SHAPES: &str = "\u{e4b3}"; +pub const SCALING: &str = "\u{e2ec}"; +pub const FRAME: &str = "\u{e291}"; +// Timeline layer-row toggles. +pub const VOLUME_2: &str = "\u{e1ab}"; // unmuted +pub const VOLUME_X: &str = "\u{e1ac}"; // muted +pub const HEADPHONES: &str = "\u{e0f1}"; // solo +pub const LOCK: &str = "\u{e10b}"; +pub const LOCK_OPEN: &str = "\u{e10c}"; +pub const EYE: &str = "\u{e0ba}"; +pub const EYE_OFF: &str = "\u{e0bb}"; +pub const VIDEO: &str = "\u{e1a5}"; // camera enabled +pub const VIDEO_OFF: &str = "\u{e1a6}"; // camera disabled + +/// Lucide glyph for tools that have no bundled SVG icon. `None` means the tool has a real SVG +/// icon in `src/assets/` and the caller should use that instead. +pub fn tool_glyph(tool: lightningbeam_core::tool::Tool) -> Option<&'static str> { + use lightningbeam_core::tool::Tool; + Some(match tool { + Tool::Pencil => PENCIL, + Tool::Pen => PEN_TOOL, + Tool::Airbrush => SPRAY_CAN, + Tool::CloneStamp => STAMP, + Tool::HealingBrush => BANDAGE, + Tool::PatternStamp => SHAPES, + Tool::DodgeBurn => SUN_MOON, + Tool::Sponge => DROPLETS, + Tool::BlurSharpen => CONTRAST, + Tool::Gradient => BLEND, + Tool::CustomShape => HEXAGON, + Tool::SelectEllipse => CIRCLE_DASHED, + Tool::MagicWand => WAND_SPARKLES, + Tool::QuickSelect => BRUSH, + Tool::Warp => SCALING, + Tool::Liquify => DROPLET, + Tool::SelectLasso => LASSO_SELECT, + _ => return None, + }) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs index 3333f01..63481ec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs @@ -160,27 +160,27 @@ pub fn gradient_stop_editor( delete_idx = Some(i); } - // Color picker popup — opens on click, closes on click-outside. - egui::containers::Popup::from_toggle_button_response(&resp) - .show(|ui| { - ui.spacing_mut().slider_width = 200.0; - let stop = &mut gradient.stops[i]; - let mut c32 = Color32::from_rgba_unmultiplied( - stop.color.r, stop.color.g, stop.color.b, stop.color.a, - ); - if egui::color_picker::color_picker_color32( - ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, - ) { - // Color32 stores premultiplied RGB; unmultiply before storing - // as straight-alpha ShapeColor to avoid darkening on round-trip. - let [pr, pg, pb, a] = c32.to_array(); - let unpm = |c: u8| -> u8 { - if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } - }; - stop.color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); - changed = true; - } - }); + // Color picker popup — opens on click, closes on a press outside it. + let stop_color = gradient.stops[i].color; + let mut c32 = Color32::from_rgba_unmultiplied( + stop_color.r, stop_color.g, stop_color.b, stop_color.a, + ); + if crate::widgets::color_swatch::color_picker_popup( + ui, + resp.id.with("stop_color_popup"), + &resp, + &mut c32, + resp.rect, + ) { + // Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha + // ShapeColor to avoid darkening on round-trip. + let [pr, pg, pb, a] = c32.to_array(); + let unpm = |c: u8| -> u8 { + if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } + }; + gradient.stops[i].color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); + changed = true; + } } // Apply drag to whichever stop selected_stop points at. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 1522f5b..7fae7bf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -28,14 +28,9 @@ pub struct InfopanelPane { tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, - /// Index of the selected paint brush preset (None = custom / unset) - selected_brush_preset: Option, - /// Whether the paint brush picker is expanded - brush_picker_expanded: bool, - /// Index of the selected eraser brush preset - selected_eraser_preset: Option, - /// Whether the eraser brush picker is expanded - eraser_picker_expanded: bool, + /// Whether each tool's brush picker is expanded. The *selected* preset lives on the tool's + /// `BrushSlot`, since it's part of the document-independent tool state, not panel chrome. + brush_picker_expanded: std::collections::HashMap, /// Cached preview textures, one per preset (populated lazily). brush_preview_textures: Vec, /// Selected stop index for gradient editor in shape section. @@ -61,15 +56,10 @@ impl InfopanelPane { // Kick off background loading of the font-picker fonts at startup so the dropdown // is ready without a hitch by the time the user opens it. lightningbeam_core::fonts::start_preload(); - let presets = bundled_brushes(); - let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { tool_section_open: true, shape_section_open: true, - selected_brush_preset: None, - brush_picker_expanded: false, - selected_eraser_preset: default_eraser_idx, - eraser_picker_expanded: false, + brush_picker_expanded: std::collections::HashMap::new(), brush_preview_textures: Vec::new(), selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, @@ -251,7 +241,7 @@ impl InfopanelPane { || is_raster_select || is_raster_shape || matches!( tool, Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect - | Tool::Warp | Tool::Liquify | Tool::Gradient + | Tool::Warp | Tool::Liquify | Tool::Gradient | Tool::Eyedropper ); if !has_options { @@ -285,12 +275,11 @@ impl InfopanelPane { return; } - // Raster paint tool: delegate to per-tool impl. + // Raster paint tool: tool-unique controls, then the shared brush controls + // (library, color, size/strength/hardness/spacing) that every brush tool gets. if let Some(def) = raster_tool_def { def.render_ui(ui, shared.raster_settings); - if def.show_brush_preset_picker() { - self.render_raster_tool_options(ui, shared, def.is_eraser()); - } + self.render_raster_tool_options(ui, shared, def); } match tool { @@ -344,7 +333,14 @@ impl InfopanelPane { ui.checkbox(shared.fill_enabled, "Fill Shape"); } + Tool::Eyedropper => { + // Which swatch the sampled color lands in. + render_color_slot_row(ui, &mut shared.raster_settings.eyedropper_use_fg); + } + Tool::PaintBucket => { + render_color_slot_row(ui, &mut shared.raster_settings.fill_use_fg); + if active_is_raster { use crate::tools::FillThresholdMode; ui.horizontal(|ui| { @@ -603,68 +599,66 @@ impl InfopanelPane { }); } - /// Render all options for a raster paint tool (brush picker + sliders). - /// `is_eraser` drives which shared state is read/written. + /// Render the shared controls every dab-painting tool has: the brush library, the FG/BG + /// color choice, and size/strength/hardness/spacing/angle. `def` supplies the tool's brush + /// slot and the label for the one slider whose meaning varies (opacity vs. exposure vs. + /// flow vs. strength). fn render_raster_tool_options( &mut self, ui: &mut Ui, shared: &mut SharedPaneState, - is_eraser: bool, + def: &'static dyn crate::tools::RasterToolDef, ) { - self.render_brush_preset_grid(ui, shared, is_eraser); + let kind = def.brush_kind(); + self.render_brush_preset_grid(ui, shared, kind); ui.add_space(2.0); - let rs = &mut shared.raster_settings; - - if !is_eraser { - ui.horizontal(|ui| { - ui.label("Color:"); - ui.selectable_value(&mut rs.brush_use_fg, true, "FG"); - ui.selectable_value(&mut rs.brush_use_fg, false, "BG"); - }); + if def.uses_color() { + render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg); } - macro_rules! field { - ($eraser:ident, $brush:ident) => { - if is_eraser { &mut rs.$eraser } else { &mut rs.$brush } - } - } + let slot = shared.raster_settings.brush_mut(kind); ui.horizontal(|ui| { ui.label("Size:"); - ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + ui.add(egui::Slider::new(&mut slot.radius, 1.0_f32..=500.0) + .logarithmic(true) + .suffix(" px")); }); ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0) + ui.label(format!("{}:", def.strength_label())); + ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Hardness:"); - ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.hardness, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Spacing:"); - ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.spacing, 0.01_f32..=20.0) .logarithmic(true) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + .custom_formatter(|v, _| format!("{:.2}", v))); }); - let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings }; - if bs.elliptical_dab_ratio > 1.001 { + if slot.settings.elliptical_dab_ratio > 1.001 { ui.horizontal(|ui| { ui.label("Angle:"); - ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0) + ui.add(egui::Slider::new(&mut slot.angle_offset, -180.0_f32..=180.0) .suffix("°") .custom_formatter(|v, _| format!("{:.0}°", v))); }); } } - /// Render the brush preset thumbnail grid (collapsible). - /// `is_eraser` drives which picker state and which shared settings are updated. - fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { + /// Render the brush preset thumbnail grid (collapsible) for one tool's brush slot. + fn render_brush_preset_grid( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + kind: crate::tools::BrushKind, + ) { let presets = bundled_brushes(); if presets.is_empty() { return; } @@ -690,8 +684,8 @@ impl InfopanelPane { } // Read picker state into locals to avoid multiple &mut self borrows. - let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded }; - let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; + let mut expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false); + let selected = shared.raster_settings.brush(kind).preset; let gap = 3.0; let cols = 2usize; @@ -762,25 +756,14 @@ impl InfopanelPane { egui::FontId::proportional(9.5), if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); if resp.clicked() { - selected = Some(idx); expanded = false; - let s = &preset.settings; - let rs = &mut shared.raster_settings; - if is_eraser { - rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); - rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); - rs.eraser_spacing = s.dabs_per_radius; - rs.active_eraser_settings = s.clone(); - } else { - rs.brush_opacity = s.opaque.clamp(0.0, 1.0); - rs.brush_hardness = s.hardness.clamp(0.0, 1.0); - rs.brush_spacing = s.dabs_per_radius; - rs.active_brush_settings = s.clone(); - // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) - // and manually picked a different brush, revert to the generic tool. - if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) { - *shared.selected_tool = Tool::Draw; - } + shared.raster_settings.brush_mut(kind).apply_preset(idx, &preset.settings); + // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) + // and manually picked a different brush, revert to the generic tool. + if kind == crate::tools::BrushKind::Paint + && matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) + { + *shared.selected_tool = Tool::Draw; } } } @@ -789,14 +772,9 @@ impl InfopanelPane { } } - // Write back picker state. - if is_eraser { - self.eraser_picker_expanded = expanded; - self.selected_eraser_preset = selected; - } else { - self.brush_picker_expanded = expanded; - self.selected_brush_preset = selected; - } + // The selected preset lives on the slot itself (written by apply_preset above); only the + // expand/collapse state is panel-local. + self.brush_picker_expanded.insert(kind, expanded); } // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) @@ -1933,3 +1911,17 @@ impl PaneRenderer for InfopanelPane { "Info Panel" } } + +/// The FG/BG selector shared by every tool that reads or writes a color (brush, paint bucket, +/// eyedropper, …), so the choice is made the same way and in the same place regardless of tool. +/// The wording stays neutral because the eyedropper *writes* to the chosen swatch while the +/// painting tools *read* from it. +fn render_color_slot_row(ui: &mut Ui, use_fg: &mut bool) { + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(use_fg, true, "FG") + .on_hover_text("Foreground (stroke) color"); + ui.selectable_value(use_fg, false, "BG") + .on_hover_text("Background (fill) color"); + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index d79eebe..ed31908 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -189,8 +189,6 @@ pub struct SharedPaneState<'a> { pub selected_tool: &'a mut Tool, pub fill_color: &'a mut egui::Color32, pub stroke_color: &'a mut egui::Color32, - /// Tracks which color (fill or stroke) was last interacted with, for eyedropper tool - pub active_color_mode: &'a mut ColorMode, pub pending_view_action: &'a mut Option, /// Tracks the priority of the best fallback pane for view actions /// Lower number = higher priority. None = no fallback pane seen yet diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 73dd94d..0f8598c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5715,9 +5715,14 @@ impl StagePane { screen_pos: egui::Pos2, shared: &mut SharedPaneState, ) { - // On click, store the screen position and color mode for sampling + // On click, store the screen position and which swatch the sample lands in. if self.rsp_clicked(response) { - self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode)); + let mode = if shared.raster_settings.eyedropper_use_fg { + super::ColorMode::Stroke + } else { + super::ColorMode::Fill + }; + self.pending_eyedropper_sample = Some((screen_pos, mode)); } } @@ -6485,15 +6490,16 @@ impl StagePane { b.dabs_per_radius = spacing; if matches!(blend_mode, RasterBlendMode::Smudge) { b.dabs_per_actual_radius = 0.0; - b.smudge_radius_log = shared.raster_settings.smudge_strength; + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, RasterBlendMode::BlurSharpen) { b.dabs_per_actual_radius = 0.0; } - let color = if matches!(blend_mode, RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { *shared.stroke_color } else { *shared.fill_color @@ -6702,7 +6708,8 @@ impl StagePane { b.dabs_per_actual_radius = 0.0; // strength controls how far behind the stroke to sample (smudge_dist multiplier). // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. - b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) { // Zero dabs_per_actual_radius so the spacing slider is the sole density control. @@ -6711,10 +6718,14 @@ impl StagePane { b }; - let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { + *shared.stroke_color + } else { + *shared.fill_color + }; let s2l = |v: u8| -> f32 { let f = v as f32 / 255.0; if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } @@ -7429,7 +7440,7 @@ impl StagePane { use lightningbeam_core::actions::PaintBucketAction; use vello::kurbo::Point; let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); - let fill_color = ShapeColor::from_egui(*shared.fill_color); + let fill_color = ShapeColor::from_egui(bucket_color(shared)); let action = PaintBucketAction::new( active_layer_id, *shared.playback_time, @@ -7483,7 +7494,7 @@ impl StagePane { return; } - let fill_egui = *shared.fill_color; + let fill_egui = bucket_color(shared); let fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()]; let threshold = shared.raster_settings.fill_threshold; let softness = shared.raster_settings.fill_softness; @@ -11580,7 +11591,11 @@ impl StagePane { // Handle tool input (only if not using Alt modifier for panning, and not while a // double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture). - if !alt_held && !self.marquee_gesture_active { + // A pen barrel button bound to Pan suppresses the tool the same way Alt does — the pen + // tip is still down while panning, so without this the brush would paint as you pan. + let pen_panning = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + if !alt_held && !pen_panning && !self.marquee_gesture_active { use lightningbeam_core::tool::Tool; // On a shape-tween in-between frame the active vector layer's geometry is an @@ -11743,9 +11758,21 @@ impl StagePane { self.pan_offset.y += scroll_delta.y; } - // Handle panning with Alt+Drag - if alt_held && response.dragged() { - // Alt+Click+Drag panning + // Middle-mouse drag pans, independent of the stage's own drag sense. + let middle_down = ui.input(|i| i.pointer.middle_down()); + if middle_down { + let delta = ui.input(|i| i.pointer.delta()); + self.pan_offset += delta; + self.is_panning = true; + } + + // Holding a pen barrel button bound to Pan turns the pen drag into a pan instead of + // a stroke (the tip is still down, so this is a normal primary drag). + let pen_pan = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + + // Handle panning with Alt+Drag (or a pan-bound pen button) + if (alt_held || pen_pan) && response.dragged() { if let Some(last_pos) = self.last_pan_pos { if let Some(current_pos) = response.interact_pointer_pos() { let delta = current_pos - last_pos; @@ -11755,7 +11782,7 @@ impl StagePane { self.last_pan_pos = response.interact_pointer_pos(); self.is_panning = true; } else { - if !response.dragged() { + if !response.dragged() && !middle_down { self.is_panning = false; self.last_pan_pos = None; } @@ -12099,26 +12126,22 @@ impl StagePane { let r = shared.raster_settings.quick_select_radius; (r, r, 0.0_f32) } else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { + // Every brush tool can carry an elliptical library brush, so shape the cursor from + // whichever slot the active tool paints with. + let slot = shared.raster_settings.brush(def.brush_kind()); let r = def.cursor_radius(shared.raster_settings); - // For the standard paint brush, also account for elliptical shape. - if matches!(*shared.selected_tool, - Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush) - { - let bs = &shared.raster_settings.active_brush_settings; - let ratio = bs.elliptical_dab_ratio.max(1.0); - let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); - (r * expand, r * expand / ratio, angle) - } else { - (r, r, 0.0_f32) - } - } else { - let bs = &shared.raster_settings.active_brush_settings; - let r = shared.raster_settings.brush_radius; + let bs = &slot.settings; let ratio = bs.elliptical_dab_ratio.max(1.0); let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); (r * expand, r * expand / ratio, angle) + } else { + let slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint); + let bs = &slot.settings; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); + (slot.radius * expand, slot.radius * expand / ratio, angle) }; let a = a_world * self.zoom; // major semi-axis in screen pixels @@ -12163,9 +12186,84 @@ impl StagePane { impl PaneRenderer for StagePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { - ui.horizontal(|ui| { - // Zoom to fit button - if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() { + // Fill most of the 40px header so the buttons are a comfortable tablet-sized target + // rather than the ~20px egui default. + const BTN_H: f32 = 27.0; + let btn_size = egui::vec2(32.0, BTN_H); + + // Lay the row out in a full-width band of exactly BTN_H centred in the header, so the + // buttons sit vertically centred instead of dropping to the bottom of the taller header + // rect. Horizontally they still start at the left, as before. + let avail = ui.max_rect(); + let band = egui::Rect::from_min_size( + egui::pos2(avail.min.x, avail.center().y - BTN_H / 2.0), + egui::vec2(avail.width(), BTN_H), + ); + + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(band) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + // Undo / redo — duplicated here so tablet users don't need the keyboard. + let can_undo = shared.action_executor.can_undo(); + let can_redo = shared.action_executor.can_redo(); + let icon_btn = |glyph: &str| { + egui::Button::new( + egui::RichText::new(glyph).font(crate::mobile::icons::font(16.0)), + ) + .min_size(btn_size) + }; + + if ui + .add_enabled(can_undo, icon_btn(crate::mobile::icons::UNDO_2)) + .on_hover_text("Undo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Undo); + } + if ui + .add_enabled(can_redo, icon_btn(crate::mobile::icons::REDO_2)) + .on_hover_text("Redo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Redo); + } + + ui.separator(); + + // Zoom to fit: Lucide "maximize" glyph followed by the label. Two fonts in one + // button means building the layout job by hand. + let fit_label = { + let mut job = egui::text::LayoutJob::default(); + let color = ui.visuals().widgets.inactive.fg_stroke.color; + job.append( + crate::mobile::icons::MAXIMIZE, + 0.0, + egui::TextFormat { + font_id: crate::mobile::icons::font(15.0), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job.append( + "Fit", + 6.0, + egui::TextFormat { + font_id: egui::TextStyle::Button.resolve(ui.style()), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job + }; + if ui + .add(egui::Button::new(fit_label).min_size(egui::vec2(0.0, BTN_H))) + .on_hover_text("Zoom to fit canvas in view") + .clicked() + { self.zoom_to_fit(shared); } @@ -12175,7 +12273,8 @@ impl PaneRenderer for StagePane { let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); ui.colored_label(text_color, format!("Zoom: {:.0}%", self.zoom * 100.0)); - }); + }, + ); true } @@ -13104,3 +13203,13 @@ impl PaneRenderer for StagePane { "Stage" } } + +/// The color the paint bucket fills with. Like the brush, the bucket chooses between the +/// foreground (stroke) and background (fill) swatch — see `RasterToolSettings::fill_use_fg`. +fn bucket_color(shared: &SharedPaneState) -> egui::Color32 { + if shared.raster_settings.fill_use_fg { + *shared.stroke_color + } else { + *shared.fill_color + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4ec6c21..42bd056 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -10,8 +10,35 @@ use eframe::egui; use daw_backend::{Beats, Seconds}; use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; +use crate::mobile::icons; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; +/// A layer-row toggle button drawn as a Lucide glyph. +fn icon_button(glyph: &str) -> egui::Button<'static> { + egui::Button::new(egui::RichText::new(glyph).font(icons::font(13.0))) +} + +/// Label a layer-row slider with a Lucide glyph just to its left — the sliders are unlabelled +/// otherwise, and volume vs. opacity are indistinguishable on layers that have both. +fn draw_slider_icon( + ui: &egui::Ui, + theme: &crate::theme::Theme, + slider_rect: egui::Rect, + glyph: &str, +) { + ui.painter().text( + egui::pos2(slider_rect.min.x - 5.0, slider_rect.center().y), + egui::Align2::RIGHT_CENTER, + glyph, + icons::font(12.0), + theme.text_color( + &["#timeline", ".slider-icon"], + ui.ctx(), + egui::Color32::from_gray(140), + ), + ); +} + const RULER_HEIGHT: f32 = 30.0; const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; @@ -2363,32 +2390,53 @@ impl TimelinePane { let Some(layer_for_controls) = any_layer_for_controls else { continue; }; - // Layer controls: volume slider top-right, buttons below it + // Layer controls, right-aligned in three stacked tiers: volume, opacity, buttons. + // A layer only shows the sliders that mean something for it (a raster layer has no + // volume; an audio layer has no opacity), but the buttons sit at a fixed offset so + // they line up across rows regardless of which sliders are present. let controls_right = header_rect.max.x - 8.0; let button_size = egui::vec2(20.0, 20.0); let slider_width = 60.0; + let slider_height = 14.0; let volume_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0), - egui::vec2(slider_width, 20.0), + egui::pos2(controls_right - slider_width, header_rect.min.y + 2.0), + egui::vec2(slider_width, slider_height), + ); + let opacity_slider_rect = egui::Rect::from_min_size( + egui::pos2(controls_right - slider_width, header_rect.min.y + 17.0), + egui::vec2(slider_width, slider_height), ); - // Buttons sit below the slider, right-aligned to match it - let buttons_top = volume_slider_rect.max.y + 4.0; - let lock_button_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - button_size.x, buttons_top), - button_size, - ); + // Which controls apply to this layer. Vector and Video layers can hold movie clips + // with audio *and* draw pixels, so they get both. + let (has_volume, has_opacity) = match layer_for_controls { + lightningbeam_core::layer::AnyLayer::Raster(_) + | lightningbeam_core::layer::AnyLayer::Text(_) => (false, true), + lightningbeam_core::layer::AnyLayer::Audio(_) => (true, false), + _ => (true, true), + }; + let is_video_layer = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let solo_button_rect = egui::Rect::from_min_size( - egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + // Buttons are laid out right-to-left, and a layer only gets the ones that mean + // something for it: [eye] [mute | camera] [solo] [lock]. + let buttons_top = header_rect.min.y + 34.0; + let mut next_x = controls_right; + let mut next_button_rect = || { + next_x -= button_size.x; + let r = egui::Rect::from_min_size(egui::pos2(next_x, buttons_top), button_size); + next_x -= 4.0; + r + }; - let mute_button_rect = egui::Rect::from_min_size( - egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + let lock_button_rect = next_button_rect(); + let solo_button_rect = has_volume.then(&mut next_button_rect); + // Video layers use this slot for the camera toggle instead of a mute button. + let mute_button_rect = + (has_volume && !is_video_layer).then(&mut next_button_rect); + let camera_button_rect = is_video_layer.then(&mut next_button_rect); + let visibility_button_rect = has_opacity.then(&mut next_button_rect); // Get layer ID and current property values from the layer we already have // Check if there's a Volume automation lane; use it to drive the slider @@ -2416,30 +2464,66 @@ impl TimelinePane { let is_soloed = layer_for_controls.soloed(); let is_locked = layer_for_controls.locked(); - // Mute button — or camera toggle for video layers - let is_video_layer = matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer_for_controls { - v.camera_enabled - } else { - false - }; + // Visibility toggle — on any layer that draws something. + if let Some(rect) = visibility_button_rect { + let is_visible = layer_for_controls.visible(); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_visible { icons::EYE } else { icons::EYE_OFF }) + .fill(if is_visible { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + } else { + theme.bg_color(&["#timeline", ".btn-hidden", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(120, 120, 120, 100)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_visible { "Hide layer" } else { "Show layer" }) + }).inner; - let first_btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| { - if is_video_layer { - // Camera toggle for video layers - let cam_text = if camera_enabled { "📹" } else { "📷" }; - let button = egui::Button::new(cam_text) + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Visible(!is_visible), + ) + )); + } + } + + // Camera toggle — video layers only. + if let Some(rect) = camera_button_rect { + let camera_enabled = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if camera_enabled { icons::VIDEO } else { icons::VIDEO_OFF }) .fill(if camera_enabled { theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) } else { theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); - ui.add(button) - } else { - // Mute button for non-video layers - let mute_text = if is_muted { "🔇" } else { "🔊" }; - let button = egui::Button::new(mute_text) + ui.add(button).on_hover_text(if camera_enabled { + "Disable camera preview" + } else { + "Enable camera preview" + }) + }).inner; + + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), + ) + )); + } + } + + // Mute button — only where there's audio to mute. + if let Some(rect) = mute_button_rect { + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_muted { icons::VOLUME_X } else { icons::VOLUME_2 }) .fill(if is_muted { theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100)) } else { @@ -2447,19 +2531,11 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) - } - }).inner; + .on_hover_text(if is_muted { "Unmute layer" } else { "Mute layer" }) + }).inner; - if first_btn_response.clicked() { - self.layer_control_clicked = true; - if is_video_layer { - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), - ) - )); - } else { + if response.clicked() { + self.layer_control_clicked = true; pending_actions.push(Box::new( lightningbeam_core::actions::SetLayerPropertiesAction::new( layer_id, @@ -2470,33 +2546,34 @@ impl TimelinePane { } // Solo button - // TODO: Replace with SVG headphones icon - let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| { - let button = egui::Button::new("🎧") - .fill(if is_soloed { - theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) - } else { - theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) - }) - .stroke(egui::Stroke::NONE); - ui.add(button) - }).inner; + // Solo is an audio control, so it follows mute: only where there's audio. + if let Some(rect) = solo_button_rect { + let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(icons::HEADPHONES) + .fill(if is_soloed { + theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) + } else { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_soloed { "Unsolo layer" } else { "Solo layer" }) + }).inner; - if solo_response.clicked() { - self.layer_control_clicked = true; - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), - ) - )); + if solo_response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), + ) + )); + } } // Lock button - // TODO: Replace with SVG lock/lock-open icons let lock_response = ui.scope_builder(egui::UiBuilder::new().max_rect(lock_button_rect), |ui| { - let lock_text = if is_locked { "🔒" } else { "🔓" }; - let button = egui::Button::new(lock_text) + let button = icon_button(if is_locked { icons::LOCK } else { icons::LOCK_OPEN }) .fill(if is_locked { theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100)) } else { @@ -2504,6 +2581,7 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) + .on_hover_text(if is_locked { "Unlock layer" } else { "Lock layer (prevent edits)" }) }).inner; if lock_response.clicked() { @@ -2516,9 +2594,40 @@ impl TimelinePane { )); } + // Opacity slider. + if has_opacity { + draw_slider_icon(ui, theme, opacity_slider_rect, icons::BLEND); + let current_opacity = layer_for_controls.opacity() as f32; + let mut new_opacity = current_opacity; + let opacity_response = ui.scope_builder( + egui::UiBuilder::new().max_rect(opacity_slider_rect), + |ui| { + ui.spacing_mut().slider_width = slider_width; + ui.add(egui::Slider::new(&mut new_opacity, 0.0..=1.0).show_value(false)) + }, + ).inner + .on_hover_text(format!("Opacity: {:.0}%", current_opacity * 100.0)); + + // Block layer drag while interacting with the slider + if opacity_response.dragged() || opacity_response.has_focus() { + self.layer_control_clicked = true; + } + if opacity_response.changed() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Opacity(new_opacity as f64), + ) + )); + } + } + // Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume) // Disabled when the user has edited the Volume automation curve beyond the default single keyframe - let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { + if has_volume { + draw_slider_icon(ui, theme, volume_slider_rect, icons::VOLUME_2); + let mut volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { ui.spacing_mut().slider_width = slider_width; // Map volume (0.0-2.0) to slider position (0.0-1.0) let slider_value = if current_volume <= 1.0 { @@ -2537,6 +2646,12 @@ impl TimelinePane { (response, temp_slider_value) }).inner; + volume_response.0 = volume_response.0.on_hover_text(if volume_is_automated { + format!("Volume: {:.0}% (automated)", current_volume * 100.0) + } else { + format!("Volume: {:.0}%", current_volume * 100.0) + }); + // Block layer drag while interacting with the slider if volume_response.0.dragged() || volume_response.0.has_focus() { self.layer_control_clicked = true; @@ -2590,13 +2705,11 @@ impl TimelinePane { } } - // Input gain slider for sampled audio layers (below volume slider) + // Input gain slider for sampled audio layers. Audio layers have no opacity, so this + // takes the opacity tier. if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls { if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled { - let gain_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0), - egui::vec2(slider_width, 16.0), - ); + let gain_slider_rect = opacity_slider_rect; let current_gain = audio_layer.layer.input_gain; // Map gain (0.0-4.0) to slider (0.0-1.0): linear @@ -2624,8 +2737,8 @@ impl TimelinePane { // Label let label_rect = egui::Rect::from_min_size( - egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0), - egui::vec2(24.0, 16.0), + egui::pos2(gain_slider_rect.min.x - 26.0, gain_slider_rect.min.y), + egui::vec2(24.0, gain_slider_rect.height()), ); ui.painter().text( label_rect.center(), @@ -2637,6 +2750,8 @@ impl TimelinePane { } } + } // end volume/gain sliders + // Per-layer VU meter bar (4px tall at bottom of header) { // Look up the track level for this layer @@ -5795,8 +5910,14 @@ impl PaneRenderer for TimelinePane { // Split into layer header column (left) and timeline content (right). On mobile the header // column collapses to a minimal color-swatch width. + // + // Once the pane gets narrow enough that the track area would be a useless sliver, drop it + // entirely and give the whole pane to the layer headers — a mixer-style view. + let headers_only = !shared.is_mobile && rect.width() < LAYER_HEADER_WIDTH * 1.5; let header_width = if shared.is_mobile { MOBILE_LAYER_HEADER_WIDTH + } else if headers_only { + rect.width() } else { LAYER_HEADER_WIDTH }; @@ -5848,20 +5969,29 @@ impl PaneRenderer for TimelinePane { ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile); - // Render time ruler (clip to ruler rect) - ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); - 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); + // The track area only exists when the pane is wide enough for it. The interaction code + // further down is naturally inert in headers-only mode, since it all gates on + // `content_rect.contains(..)` and the rect is zero-width. + let (video_clip_hovers, pending_lane_renders) = if headers_only { + (Vec::new(), Vec::new()) + } else { + // Render time ruler (clip to ruler rect) + ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); + 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)); - let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); + // Render layer rows with clipping + ui.set_clip_rect(content_rect.intersect(original_clip_rect)); + let rendered = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); - // Render playhead on top (clip to timeline area) - ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); - self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + // Render playhead on top (clip to timeline area) + ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); + self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + + rendered + }; // Restore original clip rect ui.set_clip_rect(original_clip_rect); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 28315bd..3d40366 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -29,10 +29,48 @@ impl PaneRenderer for ToolbarPane { path: &NodePath, shared: &mut SharedPaneState, ) { - let button_size = 60.0; - let button_padding = 8.0; - let button_spacing = 4.0; + // `ui` spans the whole window, not this pane — bind a child Ui to the pane's content rect + // first, or the ScrollArea would start at the window's top (under the pane header) and + // size itself against the window's height (so it would never need to scroll). + // Salt by path: widget ids inside are auto-generated from the Ui's id, so two toolbar + // panes would otherwise fight over the same ids. + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .id_salt(("toolbar", path)) + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + egui::ScrollArea::vertical() + .id_salt(("toolbar_scroll", path)) + .auto_shrink([false; 2]) + .show(&mut content_ui, |ui| { + self.render_toolbar(ui, path, shared); + }); + } + + fn name(&self) -> &str { + "Toolbar" + } +} + +const BUTTON_SIZE: f32 = 60.0; +const BUTTON_PADDING: f32 = 8.0; +const BUTTON_SPACING: f32 = 4.0; +const COLOR_BUTTON_SIZE: f32 = 50.0; +const COLOR_LABEL_WIDTH: f32 = 40.0; + +impl ToolbarPane { + /// Laid out with real egui widgets (`horizontal_wrapped` for the tool grid) rather than + /// absolute rect math, so the ScrollArea above can measure the content and scroll it when the + /// pane is too short. Buttons are still painted by hand — `allocate_exact_size` reserves the + /// space and gives us the Response, and we draw into the rect egui hands back. + fn render_toolbar( + &mut self, + ui: &mut egui::Ui, + path: &NodePath, + shared: &mut SharedPaneState, + ) { // Determine which tools to show based on the active layer type let active_layer_type: Option = shared.active_layer_id .and_then(|id| shared.action_executor.document().get_layer(&id)) @@ -52,320 +90,273 @@ impl PaneRenderer for ToolbarPane { *shared.selected_tool = Tool::Select; } - // Calculate how many columns we can fit - let available_width = rect.width() - (button_padding * 2.0); - let columns = - ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - let total_tools = tools.len(); - let total_rows = (total_tools + columns - 1) / columns; - - let mut y = rect.top() + button_padding; - - // Process tools row by row for centered layout - for row in 0..total_rows { - let start_idx = row * columns; - let end_idx = (start_idx + columns).min(total_tools); - let buttons_in_row = end_idx - start_idx; - - // Calculate the total width of buttons in this row - let row_width = (buttons_in_row as f32 * button_size) - + ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing); - - // Center the row - let mut x = rect.left() + (rect.width() - row_width) / 2.0; - - for tool_idx in start_idx..end_idx { - let tool = &tools[tool_idx]; - let button_rect = - egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(button_size, button_size)); - - // Check if this is the selected tool - let is_selected = *shared.selected_tool == *tool; - - // Button background - let bg_color = if is_selected { - shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) - } else { - shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) - }; - ui.painter().rect_filled(button_rect, 4.0, bg_color); - - // Load and render tool icon - if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { - let icon_rect = button_rect.shrink(8.0); // Padding inside button - ui.painter().image( - icon.id(), - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Draw sub-tool arrow indicator for tools with modes - let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); - if has_sub_tools { - let arrow_size = 6.0; - let margin = 4.0; - let corner = button_rect.right_bottom() - egui::vec2(margin, margin); - let tri = [ - corner, - corner - egui::vec2(arrow_size, 0.0), - corner - egui::vec2(0.0, arrow_size), - ]; - ui.painter().add(egui::Shape::convex_polygon( - tri.to_vec(), - shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), - egui::Stroke::NONE, - )); - } - - // Make button interactive (include path to ensure unique IDs across panes) - let button_id = ui.id().with(("tool_button", path, *tool as usize)); - let response = ui.interact(button_rect, button_id, egui::Sense::click()); - - // Check for click first - if response.clicked() { - *shared.selected_tool = *tool; - // Preset-backed tools: auto-select the matching bundled brush. - let preset_name = match tool { - Tool::Pencil => Some("Pencil"), - Tool::Pen => Some("Pen"), - Tool::Airbrush => Some("Airbrush"), - _ => None, - }; - if let Some(name) = preset_name { - if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) { - let s = &preset.settings; - shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0); - shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0); - shared.raster_settings.brush_spacing = s.dabs_per_radius; - shared.raster_settings.active_brush_settings = s.clone(); - } - } - } - - // Right-click context menu for tools with sub-options - if has_sub_tools { - response.context_menu(|ui| { - match tool { - Tool::RegionSelect => { - ui.set_min_width(120.0); - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Rectangle, - "Rectangle", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Rectangle; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Lasso, - "Lasso", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Lasso; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - } - Tool::SelectLasso => { - ui.set_min_width(130.0); - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Freehand, - "Freehand", - ).clicked() { - *shared.lasso_mode = LassoMode::Freehand; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Polygonal, - "Polygonal", - ).clicked() { - *shared.lasso_mode = LassoMode::Polygonal; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Magnetic, - "Magnetic", - ).clicked() { - *shared.lasso_mode = LassoMode::Magnetic; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - } - _ => {} - } - }); - } - - if response.hovered() { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), - egui::StrokeKind::Middle, - ); - } - - // Show tooltip with tool name and shortcut (consumes response). - // Hint text is pulled from the live keymap so it reflects user remappings. - let hint = tool_app_action(*tool) - .and_then(|action| shared.keymap.get(action)) - .map(|s| format!(" ({})", s.hint_text())) - .unwrap_or_default(); - let tooltip = if *tool == Tool::RegionSelect { - let mode = match *shared.region_select_mode { - RegionSelectMode::Rectangle => "Rectangle", - RegionSelectMode::Lasso => "Lasso", - }; - format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) - } else if *tool == Tool::SelectLasso { - let mode = match *shared.lasso_mode { - LassoMode::Freehand => "Freehand", - LassoMode::Polygonal => "Polygonal", - LassoMode::Magnetic => "Magnetic", - }; - format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) - } else { - format!("{}{}", tool.display_name(), hint) - }; - response.on_hover_text(tooltip); - - // Draw selection border - if is_selected { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), - egui::StrokeKind::Middle, - ); - } - - // Move to next column in this row - x += button_size + button_spacing; - } - - // Move to next row - y += button_size + button_spacing; - } - let is_raster = matches!(active_layer_type, Some(LayerType::Raster)); let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster)); - // Add color pickers below the tool buttons - if show_colors { - y += button_spacing * 2.0; // Extra spacing + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + ui.add_space(BUTTON_PADDING); - let fill_label_width = 40.0; - let color_button_size = 50.0; - let color_row_width = fill_label_width + color_button_size + button_spacing; - let color_x = rect.left() + (rect.width() - color_row_width) / 2.0; + // Centre the grid as a block. Work out how many columns fit, then lay the buttons out in + // a band of exactly that width, positioned to centre it. The band has to be an explicit + // max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits + // the parent's available width and wraps against that, not against the width we set. + let full_width = ui.available_width(); + let avail = full_width - BUTTON_PADDING * 2.0; + let columns = (((avail + BUTTON_SPACING) / (BUTTON_SIZE + BUTTON_SPACING)).floor() as usize) + .max(1) + .min(tools.len().max(1)); + let grid_width = + columns as f32 * BUTTON_SIZE + (columns.saturating_sub(1)) as f32 * BUTTON_SPACING; + let indent = ((full_width - grid_width) / 2.0).max(0.0); + let rows = (tools.len() + columns - 1) / columns; - // Two color swatches: + let cursor = ui.cursor().min; + let band = egui::Rect::from_min_size( + egui::pos2(cursor.x + indent, cursor.y), + egui::vec2(grid_width, rows as f32 * (BUTTON_SIZE + BUTTON_SPACING)), + ); + ui.scope_builder( + egui::UiBuilder::new().max_rect(band).layout( + egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), + ), + |ui| { + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + for tool in tools.iter() { + self.render_tool_button(ui, tool, shared); + } + }, + ); + + // Colour swatches below the tools. // Stroke/FG always on top, Fill/BG always on bottom. // Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill". - { - let stroke_label = if is_raster { "FG" } else { "Stroke" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); - ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), - egui::Align2::CENTER_CENTER, - stroke_label, - egui::FontId::proportional(14.0), - label_color, - ); + if show_colors { + ui.add_space(BUTTON_SPACING * 2.0); - let stroke_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let stroke_button_id = ui.id().with(("stroke_color_button", path)); - let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click()); - draw_color_button(ui, stroke_button_rect, *shared.stroke_color); - egui::containers::Popup::from_toggle_button_response(&stroke_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Stroke; - } + let row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING; + let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0); + + for (label, is_stroke) in [ + (if is_raster { "FG" } else { "Stroke" }, true), + (if is_raster { "BG" } else { "Fill" }, false), + ] { + ui.horizontal(|ui| { + ui.add_space(color_indent); + + let (label_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_LABEL_WIDTH, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let label_color = shared.theme.text_color( + &["#toolbar", ".text-secondary"], + ui.ctx(), + egui::Color32::from_gray(200), + ); + ui.painter().text( + label_rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(14.0), + label_color, + ); + + let (swatch_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_BUTTON_SIZE, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let (id_key, color) = if is_stroke { + ("stroke_color_button", &mut *shared.stroke_color) + } else { + ("fill_color_button", &mut *shared.fill_color) + }; + let button_id = ui.id().with((id_key, path)); + crate::widgets::color_swatch::color_swatch(ui, button_id, swatch_rect, color); }); - - y += color_button_size + button_spacing; + } } - // Fill/BG color swatch - { - let fill_label = if is_raster { "BG" } else { "Fill" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); + ui.add_space(BUTTON_PADDING); + } + + fn render_tool_button( + &mut self, + ui: &mut egui::Ui, + tool: &Tool, + shared: &mut SharedPaneState, + ) { + let (button_rect, response) = ui.allocate_exact_size( + egui::vec2(BUTTON_SIZE, BUTTON_SIZE), + egui::Sense::click(), + ); + + let is_selected = *shared.selected_tool == *tool; + + // Button background + let bg_color = if is_selected { + shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) + } else { + shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) + }; + ui.painter().rect_filled(button_rect, 4.0, bg_color); + + // Tool icon: tools without a bundled SVG fall back to a Lucide glyph rather than the + // shared TODO placeholder. + if let Some(glyph) = crate::mobile::icons::tool_glyph(*tool) { ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), + button_rect.center(), egui::Align2::CENTER_CENTER, - fill_label, - egui::FontId::proportional(14.0), - label_color, - ); - - let fill_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let fill_button_id = ui.id().with(("fill_color_button", path)); - let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click()); - draw_color_button(ui, fill_button_rect, *shared.fill_color); - egui::containers::Popup::from_toggle_button_response(&fill_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Fill; - } - }); - } - } // end color pickers - } - - fn name(&self) -> &str { - "Toolbar" - } -} - -/// Draw a color button with checkerboard background for alpha channel -fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { - // Draw checkerboard background - let checker_size = 5.0; - let cols = (rect.width() / checker_size).ceil() as usize; - let rows = (rect.height() / checker_size).ceil() as usize; - - for row in 0..rows { - for col in 0..cols { - let is_light = (row + col) % 2 == 0; - let checker_color = if is_light { - egui::Color32::from_gray(180) - } else { - egui::Color32::from_gray(120) - }; - let checker_rect = egui::Rect::from_min_size( - egui::pos2( - rect.min.x + col as f32 * checker_size, - rect.min.y + row as f32 * checker_size, + glyph, + crate::mobile::icons::font(26.0), + shared.theme.text_color( + &["#toolbar", ".tool-button"], + ui.ctx(), + egui::Color32::from_gray(220), ), - egui::vec2(checker_size, checker_size), - ).intersect(rect); - ui.painter().rect_filled(checker_rect, 0.0, checker_color); + ); + } else if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { + let icon_rect = button_rect.shrink(8.0); // Padding inside button + ui.painter().image( + icon.id(), + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); } + + // Draw sub-tool arrow indicator for tools with modes + let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); + if has_sub_tools { + let arrow_size = 6.0; + let margin = 4.0; + let corner = button_rect.right_bottom() - egui::vec2(margin, margin); + let tri = [ + corner, + corner - egui::vec2(arrow_size, 0.0), + corner - egui::vec2(0.0, arrow_size), + ]; + ui.painter().add(egui::Shape::convex_polygon( + tri.to_vec(), + shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), + egui::Stroke::NONE, + )); + } + + if response.clicked() { + *shared.selected_tool = *tool; + // Preset-backed tools: auto-select the matching bundled brush. + let preset_name = match tool { + Tool::Pencil => Some("Pencil"), + Tool::Pen => Some("Pen"), + Tool::Airbrush => Some("Airbrush"), + _ => None, + }; + if let Some(name) = preset_name { + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == name) { + let settings = bundled_brushes()[idx].settings.clone(); + shared + .raster_settings + .brush_mut(crate::tools::BrushKind::Paint) + .apply_preset(idx, &settings); + } + } + } + + // Right-click context menu for tools with sub-options + if has_sub_tools { + response.context_menu(|ui| { + match tool { + Tool::RegionSelect => { + ui.set_min_width(120.0); + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Rectangle, + "Rectangle", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Rectangle; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Lasso, + "Lasso", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Lasso; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + } + Tool::SelectLasso => { + ui.set_min_width(130.0); + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Freehand, + "Freehand", + ).clicked() { + *shared.lasso_mode = LassoMode::Freehand; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Polygonal, + "Polygonal", + ).clicked() { + *shared.lasso_mode = LassoMode::Polygonal; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Magnetic, + "Magnetic", + ).clicked() { + *shared.lasso_mode = LassoMode::Magnetic; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + } + _ => {} + } + }); + } + + if response.hovered() { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), + egui::StrokeKind::Middle, + ); + } + + // Draw selection border + if is_selected { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), + egui::StrokeKind::Middle, + ); + } + + // Show tooltip with tool name and shortcut (consumes response). + // Hint text is pulled from the live keymap so it reflects user remappings. + let hint = tool_app_action(*tool) + .and_then(|action| shared.keymap.get(action)) + .map(|s| format!(" ({})", s.hint_text())) + .unwrap_or_default(); + let tooltip = if *tool == Tool::RegionSelect { + let mode = match *shared.region_select_mode { + RegionSelectMode::Rectangle => "Rectangle", + RegionSelectMode::Lasso => "Lasso", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else if *tool == Tool::SelectLasso { + let mode = match *shared.lasso_mode { + LassoMode::Freehand => "Freehand", + LassoMode::Polygonal => "Polygonal", + LassoMode::Magnetic => "Magnetic", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else { + format!("{}{}", tool.display_name(), hint) + }; + response.on_hover_text(tooltip); } - - // Draw color on top - ui.painter().rect_filled(rect, 2.0, color); - - // Draw border - ui.painter().rect_stroke( - rect, - 2.0, - egui::Stroke::new(1.0, egui::Color32::from_gray(80)), - egui::StrokeKind::Middle, - ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 7ef5391..a0bfc9f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use eframe::egui; -use crate::config::AppConfig; +use crate::config::{AppConfig, TabletButtonAction}; use crate::keymap::{self, AppAction, KeymapManager}; use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::theme::{Theme, ThemeMode}; @@ -61,6 +61,8 @@ struct PreferencesState { waveform_stereo: bool, theme_mode: ThemeMode, large_media_default: LargeMediaMode, + tablet_button_lower: TabletButtonAction, + tablet_button_upper: TabletButtonAction, } impl From<(&AppConfig, &Theme)> for PreferencesState { @@ -78,6 +80,8 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { waveform_stereo: config.waveform_stereo, theme_mode: theme.mode(), large_media_default: config.large_media_default, + tablet_button_lower: config.tablet_button_lower, + tablet_button_upper: config.tablet_button_upper, } } } @@ -97,6 +101,8 @@ impl Default for PreferencesState { waveform_stereo: false, theme_mode: ThemeMode::System, large_media_default: LargeMediaMode::default(), + tablet_button_lower: TabletButtonAction::Pan, + tablet_button_upper: TabletButtonAction::Eyedropper, } } } @@ -239,6 +245,8 @@ impl PreferencesDialog { ui.add_space(8.0); self.render_startup_section(ui); ui.add_space(8.0); + self.render_tablet_section(ui); + ui.add_space(8.0); self.render_advanced_section(ui); }); } @@ -591,6 +599,41 @@ impl PreferencesDialog { }); } + fn render_tablet_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Tablet") + .default_open(false) + .show(ui, |ui| { + ui.label("What each barrel button on the stylus does while held:"); + ui.add_space(4.0); + + let button_row = |ui: &mut egui::Ui, label: &str, id: &str, value: &mut TabletButtonAction| { + ui.horizontal(|ui| { + ui.label(label); + egui::ComboBox::from_id_salt(id) + .selected_text(value.label()) + .show_ui(ui, |ui| { + for action in TabletButtonAction::ALL { + ui.selectable_value(value, action, action.label()); + } + }); + }); + }; + + button_row( + ui, + "Lower button:", + "tablet_button_lower", + &mut self.working_prefs.tablet_button_lower, + ); + button_row( + ui, + "Upper button:", + "tablet_button_upper", + &mut self.working_prefs.tablet_button_upper, + ); + }); + } + fn render_advanced_section(&mut self, ui: &mut egui::Ui) { egui::CollapsingHeader::new("Advanced") .default_open(false) @@ -646,6 +689,8 @@ impl PreferencesDialog { temp_config.debug = self.working_prefs.debug; temp_config.waveform_stereo = self.working_prefs.waveform_stereo; temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + temp_config.tablet_button_lower = self.working_prefs.tablet_button_lower; + temp_config.tablet_button_upper = self.working_prefs.tablet_button_upper; // Validate if let Err(err) = temp_config.validate() { @@ -681,10 +726,16 @@ impl PreferencesDialog { config.waveform_stereo = self.working_prefs.waveform_stereo; config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); config.large_media_default = self.working_prefs.large_media_default; + config.tablet_button_lower = self.working_prefs.tablet_button_lower; + config.tablet_button_upper = self.working_prefs.tablet_button_upper; config.keybindings = keybinding_config; // Apply theme immediately theme.set_mode(self.working_prefs.theme_mode); + crate::tablet::set_button_actions( + self.working_prefs.tablet_button_lower, + self.working_prefs.tablet_button_upper, + ); // Save to disk config.save(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs index 8186b4f..6d76770 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs @@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU32, Ordering}; use eframe::egui; use lightningbeam_core::tool::Tool; +use crate::config::TabletButtonAction; + // --------------------------------------------------------------------------- // Global tablet state — read by make_stroke_point() on the UI thread // --------------------------------------------------------------------------- @@ -21,6 +23,58 @@ static TABLET_PRESSURE_BITS: AtomicU32 = AtomicU32::new(0x3f800000); // 1.0f32 static TABLET_TILT_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 static TABLET_TILT_Y_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 +/// Bit 0 = lower barrel button held, bit 1 = upper. Read on the UI thread by the stage to +/// decide whether the pen is currently panning. +static TABLET_BUTTONS: AtomicU32 = AtomicU32::new(0); + +fn button_bit(b: TabletButton) -> u32 { + match b { + TabletButton::Lower => 1, + TabletButton::Upper => 2, + } +} + +/// Is the given barrel button currently held? +pub fn button_held(b: TabletButton) -> bool { + TABLET_BUTTONS.load(Ordering::Relaxed) & button_bit(b) != 0 +} + +/// The configured action for each barrel button, mirrored from `AppConfig` so the stage can +/// read it without threading config through every call site. +static BUTTON_ACTIONS: std::sync::Mutex<(TabletButtonAction, TabletButtonAction)> = + std::sync::Mutex::new((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + +/// Mirror the configured barrel-button actions. Call on startup and whenever preferences change. +pub fn set_button_actions(lower: TabletButtonAction, upper: TabletButtonAction) { + if let Ok(mut a) = BUTTON_ACTIONS.lock() { + *a = (lower, upper); + } +} + +fn action_for(b: TabletButton) -> TabletButtonAction { + let actions = BUTTON_ACTIONS + .lock() + .map(|a| *a) + .unwrap_or((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + match b { + TabletButton::Lower => actions.0, + TabletButton::Upper => actions.1, + } +} + +/// The action of whichever barrel button is currently held (lower wins if both are). +pub fn active_button_action() -> Option { + for b in [TabletButton::Lower, TabletButton::Upper] { + if button_held(b) { + let a = action_for(b); + if a != TabletButtonAction::None { + return Some(a); + } + } + } + None +} + /// Current pen pressure (0.0–1.0). Falls back to 1.0 when no tablet is active. pub fn current_pressure() -> f32 { f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed)) @@ -57,10 +111,32 @@ pub enum RawTabletEvent { Tilt { x: f32, y: f32 }, TipDown, TipUp, + /// A barrel button on the pen was pressed or released. + Button { button: TabletButton, pressed: bool }, /// End of Wayland tablet event group; commit accumulated state. Frame, } +/// Which barrel button on the stylus. Most pens have two; some have a third. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabletButton { + Lower, + Upper, +} + +impl TabletButton { + /// Map a Linux evdev button code (used by both the Wayland tablet protocol and X11's + /// underlying device) to a barrel button. + fn from_evdev(code: u32) -> Option { + // BTN_STYLUS = 0x14b, BTN_STYLUS2 = 0x14c, BTN_STYLUS3 = 0x149. + match code { + 0x14b => Some(TabletButton::Lower), + 0x14c => Some(TabletButton::Upper), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum TabletToolType { #[default] @@ -91,9 +167,13 @@ pub struct TabletInput { /// On X11, clicks already come through winit via OS mouse emulation. inject_buttons: bool, - /// Pending tool switch (eraser in/out). Consumed by `EditorApp::update()`. + /// Pending tool switch (eraser in/out, barrel-button override). Consumed by `EditorApp::update()`. pub pending_tool_switch: Option, tool_before_eraser: Option, + /// Barrel-button state as of last frame, for press/release edge detection. + prev_buttons: u32, + /// Tool to restore when the barrel button driving a tool override is released. + tool_before_button: Option, /// One-shot sender used to hand the egui Context to the background thread /// on the first poll() call so it can call request_repaint(). @@ -125,6 +205,8 @@ impl TabletInput { inject_buttons, pending_tool_switch: None, tool_before_eraser: None, + prev_buttons: 0, + tool_before_button: None, repaint_sender, backend, } @@ -202,6 +284,9 @@ impl TabletInput { // Handle eraser tool switch. self.handle_tool_switch(current_tool); + // Handle barrel-button tool overrides (eyedropper / eraser held on the pen). + self.handle_button_tool_override(current_tool); + // Publish globals for make_stroke_point(). set_pressure(self.pressure); let (tx, ty) = self.tilt; @@ -267,6 +352,8 @@ impl TabletInput { RawTabletEvent::ProximityOut => { self.in_proximity = false; self.pressure = 0.0; + // Don't let a button stay latched if the pen leaves while it's held. + TABLET_BUTTONS.store(0, Ordering::Relaxed); } RawTabletEvent::Motion { x, y } => { // Wayland gives physical pixels; convert to egui logical points. @@ -289,6 +376,14 @@ impl TabletInput { RawTabletEvent::TipUp => { self.tip_down = Some(false); } + RawTabletEvent::Button { button, pressed } => { + let bit = button_bit(button); + if pressed { + TABLET_BUTTONS.fetch_or(bit, Ordering::Relaxed); + } else { + TABLET_BUTTONS.fetch_and(!bit, Ordering::Relaxed); + } + } RawTabletEvent::Frame => { // Frame is the commit signal; processing already happened in individual events. } @@ -318,6 +413,39 @@ impl TabletInput { } } + /// Barrel buttons bound to Eyedropper/Erase act as a spring-loaded tool override: switch + /// while held, restore on release. Pan is handled by the stage instead, since it needs the + /// drag delta rather than a tool. + fn handle_button_tool_override(&mut self, current_tool: Tool) { + let buttons = TABLET_BUTTONS.load(Ordering::Relaxed); + if buttons == self.prev_buttons { + return; + } + self.prev_buttons = buttons; + + let override_tool = active_button_action().and_then(|a| match a { + TabletButtonAction::Eyedropper => Some(Tool::Eyedropper), + TabletButtonAction::Erase => Some(Tool::Erase), + TabletButtonAction::Pan | TabletButtonAction::None => None, + }); + + match (override_tool, self.tool_before_button) { + // Button pressed: remember the tool we're overriding, then switch. + (Some(tool), None) => { + if current_tool != tool { + self.tool_before_button = Some(current_tool); + self.pending_tool_switch = Some(tool); + } + } + // Button released: restore. + (None, Some(prev)) => { + self.tool_before_button = None; + self.pending_tool_switch = Some(prev); + } + _ => {} + } + } + fn handle_tool_switch(&mut self, current_tool: Tool) { let just_entered = self.in_proximity && !self.was_in_proximity; let just_left = !self.in_proximity && self.was_in_proximity; @@ -343,7 +471,7 @@ impl TabletInput { #[cfg(target_os = "linux")] mod wayland { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use eframe::egui; use std::collections::HashMap; use std::sync::mpsc; @@ -445,6 +573,7 @@ mod wayland { pending_motion: bool, pending_tip: Option, pending_proximity: Option, + pending_buttons: Vec<(TabletButton, bool)>, } // ----------------------------------------------------------------------- @@ -585,7 +714,20 @@ mod wayland { Event::Tilt { tilt_x, tilt_y } => { acc.pending_tilt = (tilt_x as f32, tilt_y as f32); } + Event::Button { button, state: btn_state, .. } => { + // `button` is a Linux evdev code (BTN_STYLUS / BTN_STYLUS2). + if let Some(b) = TabletButton::from_evdev(button) { + let pressed = matches!( + btn_state.into_result(), + Ok(zwp_tablet_tool_v2::ButtonState::Pressed) + ); + acc.pending_buttons.push((b, pressed)); + } + } Event::Frame { .. } => { + for (b, pressed) in acc.pending_buttons.drain(..) { + let _ = state.tx.send(RawTabletEvent::Button { button: b, pressed }); + } // Flush accumulated events to the channel. if let Some(prox) = acc.pending_proximity.take() { if prox { @@ -630,7 +772,7 @@ mod wayland { #[cfg(target_os = "linux")] mod x11 { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use std::sync::mpsc; use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle}; @@ -880,17 +1022,44 @@ mod x11 { } Event::XinputRawButtonPress(raw) => { - if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipDown); - let _ = tx.send(RawTabletEvent::Frame); + if device_axes.contains_key(&raw.deviceid) { + // X11 reports the tip as button 1 and the barrel buttons as 2 and 3. + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipDown); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: true }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} + } } } Event::XinputRawButtonRelease(raw) => { if device_axes.contains_key(&raw.deviceid) { - if raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipUp); - let _ = tx.send(RawTabletEvent::Frame); + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipUp); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: false }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} } // When all buttons released, synthesise proximity out. in_proximity.remove(&raw.deviceid); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs index 876b5d6..367e0f6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct BlurSharpenTool; pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; @@ -8,19 +8,11 @@ pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; impl RasterToolDef for BlurSharpenTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } fn header_label(&self) -> &'static str { "Blur / Sharpen" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.blur_sharpen_radius, - opacity: s.blur_sharpen_strength, - hardness: s.blur_sharpen_hardness, - spacing: s.blur_sharpen_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::BlurSharpen } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Strength" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() { @@ -30,31 +22,11 @@ impl RasterToolDef for BlurSharpenTool { s.blur_sharpen_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); ui.horizontal(|ui| { ui.label("Kernel:"); ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) .logarithmic(true) .custom_formatter(|v, _| format!("{:.1} px", v))); }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs index bbfb9f2..c623ecc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static CLONE_STAMP: CloneStampTool = CloneStampTool; impl RasterToolDef for CloneStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } fn header_label(&self) -> &'static str { "Clone Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::CloneStamp } /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time /// (offset = clone_source - stroke_start), not from settings directly. fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs index 7ab1968..2b8279d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct DodgeBurnTool; pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; @@ -8,19 +8,11 @@ pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; impl RasterToolDef for DodgeBurnTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } fn header_label(&self) -> &'static str { "Dodge / Burn" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.dodge_burn_radius, - opacity: s.dodge_burn_exposure, - hardness: s.dodge_burn_hardness, - spacing: s.dodge_burn_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::DodgeBurn } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Exposure" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for DodgeBurnTool { s.dodge_burn_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Exposure:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs index 361858a..a1f3102 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct EraseTool; @@ -8,16 +7,6 @@ pub static ERASE: EraseTool = EraseTool; impl RasterToolDef for EraseTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } fn header_label(&self) -> &'static str { "Eraser" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_eraser_settings.clone(), - radius: s.eraser_radius, - opacity: s.eraser_opacity, - hardness: s.eraser_hardness, - spacing: s.eraser_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn is_eraser(&self) -> bool { true } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Erase } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs index c6ae87a..1a920dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool; impl RasterToolDef for HealingBrushTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } fn header_label(&self) -> &'static str { "Healing Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::HealingBrush } /// tool_params are filled by stage.rs at stroke-start time (clone offset). fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } fn uses_alt_click(&self) -> bool { true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 80efc73..60cf8d7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -2,12 +2,17 @@ /// /// Each tool implements `RasterToolDef`. Adding a new tool requires: /// 1. A new file in this directory implementing `RasterToolDef`. -/// 2. One entry in `raster_tool_def()` below. +/// 2. A `BrushKind` variant (if it paints dabs) and one entry in `raster_tool_def()` below. /// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch. +/// +/// Every dab-painting tool owns a `BrushSlot`: its own size/strength/hardness/spacing, its own +/// brush from the shared `.myb` library, and its own FG/BG color choice. The blend mode is what +/// makes a tool a brush vs. an eraser vs. dodge/burn — the brush shape is orthogonal, so all of +/// them get the same library and the same controls. use eframe::egui; use lightningbeam_core::{ - brush_settings::BrushSettings, + brush_settings::{bundled_brushes, BrushSettings}, raster_layer::RasterBlendMode, tool::Tool, }; @@ -22,6 +27,86 @@ pub mod dodge_burn; pub mod sponge; pub mod blur_sharpen; +// --------------------------------------------------------------------------- +// Brush slots — one per dab-painting tool +// --------------------------------------------------------------------------- + +/// Identifies a tool's brush slot. Each slot remembers its own brush independently, so +/// switching from Dodge to Sponge doesn't clobber either one's size or preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BrushKind { + Paint, + Erase, + Smudge, + CloneStamp, + HealingBrush, + PatternStamp, + DodgeBurn, + Sponge, + BlurSharpen, +} + +impl BrushKind { + fn index(self) -> usize { + match self { + BrushKind::Paint => 0, + BrushKind::Erase => 1, + BrushKind::Smudge => 2, + BrushKind::CloneStamp => 3, + BrushKind::HealingBrush => 4, + BrushKind::PatternStamp => 5, + BrushKind::DodgeBurn => 6, + BrushKind::Sponge => 7, + BrushKind::BlurSharpen => 8, + } + } +} + +/// One tool's brush: the shared controls every dab-painting tool has. +#[derive(Debug, Clone)] +pub struct BrushSlot { + pub radius: f32, + /// What this means is per-tool — opacity, exposure, flow, strength. The label comes from + /// [`RasterToolDef::strength_label`]. + pub strength: f32, + pub hardness: f32, + pub spacing: f32, + /// The brush shape, from the bundled `.myb` library. + pub settings: BrushSettings, + /// Index into `bundled_brushes()` of the selected preset, if any. + pub preset: Option, + /// Added to the preset's `elliptical_dab_angle` (degrees), so stock brushes can be + /// re-oriented without editing the file. + pub angle_offset: f32, + /// true = paint with the FG (stroke) color, false = BG (fill). Ignored when the tool + /// doesn't use a color (see [`RasterToolDef::uses_color`]). + pub use_fg: bool, +} + +impl BrushSlot { + fn new(radius: f32, strength: f32, hardness: f32, spacing: f32) -> Self { + Self { + radius, + strength, + hardness, + spacing, + settings: BrushSettings::default(), + preset: None, + angle_offset: 0.0, + use_fg: true, + } + } + + /// Adopt a brush preset, pulling its opacity/hardness/spacing along with the shape. + pub fn apply_preset(&mut self, index: usize, settings: &BrushSettings) { + self.preset = Some(index); + self.strength = settings.opaque.clamp(0.0, 1.0); + self.hardness = settings.hardness.clamp(0.0, 1.0); + self.spacing = settings.dabs_per_radius; + self.settings = settings.clone(); + } +} + // --------------------------------------------------------------------------- // Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) // --------------------------------------------------------------------------- @@ -29,25 +114,8 @@ pub mod blur_sharpen; /// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed /// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. pub struct RasterToolSettings { - // --- Paint brush --- - pub brush_radius: f32, - pub brush_opacity: f32, - pub brush_hardness: f32, - pub brush_spacing: f32, - /// true = paint with FG (stroke) color, false = BG (fill) color - pub brush_use_fg: bool, - pub active_brush_settings: BrushSettings, - // --- Eraser --- - pub eraser_radius: f32, - pub eraser_opacity: f32, - pub eraser_hardness: f32, - pub eraser_spacing: f32, - pub active_eraser_settings: BrushSettings, - // --- Smudge --- - pub smudge_radius: f32, - pub smudge_hardness: f32, - pub smudge_spacing: f32, - pub smudge_strength: f32, + /// Per-tool brush state, indexed by `BrushKind`. + brushes: [BrushSlot; 9], // --- Clone / Healing --- /// World-space source point set by Alt+click. pub clone_source: Option, @@ -55,24 +123,12 @@ pub struct RasterToolSettings { pub pattern_type: u32, pub pattern_scale: f32, // --- Dodge / Burn --- - pub dodge_burn_radius: f32, - pub dodge_burn_hardness: f32, - pub dodge_burn_spacing: f32, - pub dodge_burn_exposure: f32, /// 0 = dodge (lighten), 1 = burn (darken) pub dodge_burn_mode: u32, // --- Sponge --- - pub sponge_radius: f32, - pub sponge_hardness: f32, - pub sponge_spacing: f32, - pub sponge_flow: f32, /// 0 = saturate, 1 = desaturate pub sponge_mode: u32, // --- Blur / Sharpen --- - pub blur_sharpen_radius: f32, - pub blur_sharpen_hardness: f32, - pub blur_sharpen_spacing: f32, - pub blur_sharpen_strength: f32, /// Neighborhood kernel radius in canvas pixels (1–20) pub blur_sharpen_kernel: f32, /// 0 = blur, 1 = sharpen @@ -87,7 +143,7 @@ pub struct RasterToolSettings { // --- Quick Select --- /// Brush radius in canvas pixels for the quick-select tool. pub quick_select_radius: f32, - // --- Flood fill (Paint Bucket, raster) --- + // --- Flood fill (Paint Bucket) --- /// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this /// distance of the comparison color are included in the fill. pub fill_threshold: f32, @@ -96,6 +152,11 @@ pub struct RasterToolSettings { /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS /// parent pixel (Relative, spreads across gradients). pub fill_threshold_mode: FillThresholdMode, + /// true = fill with the FG (stroke) color, false = BG (fill). Mirrors `BrushSlot::use_fg`. + pub fill_use_fg: bool, + // --- Eyedropper --- + /// true = sampled color replaces the FG (stroke) swatch, false = the BG (fill) swatch. + pub eyedropper_use_fg: bool, // --- Marquee select shape --- /// Whether the rectangular select tool draws a rect or an ellipse. pub select_shape: SelectionShape, @@ -109,10 +170,16 @@ pub struct RasterToolSettings { // --- Gradient --- pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient_opacity: f32, - // --- Brush rotation offset --- - /// User-controlled angle offset added to the brush's elliptical_dab_angle (degrees). - /// Lets the user re-orient stock .myb brushes without editing the file. - pub brush_angle_offset: f32, +} + +impl RasterToolSettings { + pub fn brush(&self, kind: BrushKind) -> &BrushSlot { + &self.brushes[kind.index()] + } + + pub fn brush_mut(&mut self, kind: BrushKind) -> &mut BrushSlot { + &mut self.brushes[kind.index()] + } } /// Brush mode for the Liquify tool. @@ -158,43 +225,32 @@ pub enum FillThresholdMode { impl Default for RasterToolSettings { fn default() -> Self { + // The eraser defaults to the bundled "Brush" shape rather than a bare Gaussian. + let mut erase = BrushSlot::new(10.0, 1.0, 0.5, 0.1); + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == "Brush") { + erase.apply_preset(idx, &bundled_brushes()[idx].settings); + // Keep the eraser's own size/opacity rather than the preset's. + erase.radius = 10.0; + erase.strength = 1.0; + } + Self { - brush_radius: 10.0, - brush_opacity: 1.0, - brush_hardness: 0.5, - brush_spacing: 0.1, - brush_use_fg: true, - active_brush_settings: BrushSettings::default(), - eraser_radius: 10.0, - eraser_opacity: 1.0, - eraser_hardness: 0.5, - eraser_spacing: 0.1, - active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes() - .iter() - .find(|p| p.name == "Brush") - .map(|p| p.settings.clone()) - .unwrap_or_default(), - smudge_radius: 15.0, - smudge_hardness: 0.8, - smudge_spacing: 8.0, - smudge_strength: 1.0, + brushes: [ + /* Paint */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* Erase */ erase, + /* Smudge */ BrushSlot::new(15.0, 1.0, 0.8, 8.0), + /* CloneStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* HealingBrush */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* PatternStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* DodgeBurn */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* Sponge */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* BlurSharpen */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + ], clone_source: None, pattern_type: 0, pattern_scale: 32.0, - dodge_burn_radius: 30.0, - dodge_burn_hardness: 0.5, - dodge_burn_spacing: 3.0, - dodge_burn_exposure: 0.5, dodge_burn_mode: 0, - sponge_radius: 30.0, - sponge_hardness: 0.5, - sponge_spacing: 3.0, - sponge_flow: 0.5, sponge_mode: 0, - blur_sharpen_radius: 30.0, - blur_sharpen_hardness: 0.5, - blur_sharpen_spacing: 3.0, - blur_sharpen_strength: 0.5, blur_sharpen_kernel: 5.0, blur_sharpen_mode: 0, wand_threshold: 15.0, @@ -203,6 +259,8 @@ impl Default for RasterToolSettings { fill_threshold: 15.0, fill_softness: 0.0, fill_threshold_mode: FillThresholdMode::Absolute, + fill_use_fg: true, + eyedropper_use_fg: true, quick_select_radius: 20.0, select_shape: SelectionShape::Rect, warp_grid_cols: 4, @@ -212,7 +270,6 @@ impl Default for RasterToolSettings { liquify_strength: 0.5, gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient_opacity: 1.0, - brush_angle_offset: 0.0, } } } @@ -229,6 +286,21 @@ pub struct BrushParams { pub spacing: f32, } +/// Read a slot straight into `BrushParams`. This is what `RasterToolDef::brush_params` does by +/// default; it's a free function so a tool that overrides `brush_params` can still build on it. +pub fn default_brush_params(kind: BrushKind, s: &RasterToolSettings) -> BrushParams { + let slot = s.brush(kind); + let mut base_settings = slot.settings.clone(); + base_settings.elliptical_dab_angle += slot.angle_offset; + BrushParams { + base_settings, + radius: slot.radius, + opacity: slot.strength, + hardness: slot.hardness, + spacing: slot.spacing, + } +} + // --------------------------------------------------------------------------- // RasterToolDef trait // --------------------------------------------------------------------------- @@ -236,19 +308,34 @@ pub struct BrushParams { pub trait RasterToolDef: Send + Sync { fn blend_mode(&self) -> RasterBlendMode; fn header_label(&self) -> &'static str; - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams; + /// Which brush slot this tool paints with. + fn brush_kind(&self) -> BrushKind; /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`. fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; + + /// Brush shape + size for this stroke. The default pulls everything from the tool's slot; + /// override only if a tool needs to reinterpret one of the fields (see `smudge`). + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + default_brush_params(self.brush_kind(), s) + } + /// Cursor display radius (world pixels). fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { self.brush_params(s).radius } - /// Render tool-specific controls in the infopanel (called before preset picker if any). - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings); - /// Whether to show the brush preset picker after `render_ui`. - fn show_brush_preset_picker(&self) -> bool { true } - /// Whether this tool is the eraser (drives preset picker + color UI visibility). - fn is_eraser(&self) -> bool { false } + + /// Label for the slot's `strength` slider — the one field whose meaning is tool-specific. + fn strength_label(&self) -> &'static str { "Opacity" } + + /// Render tool-specific controls in the infopanel. The shared brush controls (color, size, + /// strength, hardness, spacing, angle, preset library) are rendered around this by the + /// infopanel — only put genuinely tool-unique widgets here. + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + + /// Whether this tool paints with the FG/BG color. False for tools that only transform + /// pixels already on the canvas (erase, smudge, dodge/burn, sponge, blur, clone, heal). + fn uses_color(&self) -> bool { false } + /// Whether Alt+click sets a source point for this tool. fn uses_alt_click(&self) -> bool { false } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs index 6d3e192..7c632aa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct PaintTool; @@ -8,17 +7,7 @@ pub static PAINT: PaintTool = PaintTool; impl RasterToolDef for PaintTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn header_label(&self) -> &'static str { "Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - let mut base_settings = s.active_brush_settings.clone(); - base_settings.elliptical_dab_angle += s.brush_angle_offset; - BrushParams { - base_settings, - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Paint } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_color(&self) -> bool { true } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs index acd83d3..e3b8445 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -12,18 +12,12 @@ const PATTERN_NAMES: &[&str] = &[ impl RasterToolDef for PatternStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } fn header_label(&self) -> &'static str { "Pattern Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::PatternStamp } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] } + /// The pattern is stamped in the brush color (see `brush_dab.wgsl`, blend mode 5). + fn uses_color(&self) -> bool { true } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { let selected_name = PATTERN_NAMES .get(s.pattern_type as usize) @@ -44,6 +38,5 @@ impl RasterToolDef for PatternStampTool { ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0) .logarithmic(true).suffix(" px")); }); - ui.add_space(4.0); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs index b79eeb8..0adf976 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs @@ -1,6 +1,5 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SmudgeTool; pub static SMUDGE: SmudgeTool = SmudgeTool; @@ -8,37 +7,15 @@ pub static SMUDGE: SmudgeTool = SmudgeTool; impl RasterToolDef for SmudgeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } fn header_label(&self) -> &'static str { "Smudge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.smudge_radius, - opacity: 1.0, // strength is a separate smudge_dist multiplier - hardness: s.smudge_hardness, - spacing: s.smudge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Smudge } fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn show_brush_preset_picker(&self) -> bool { false } - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); + fn strength_label(&self) -> &'static str { "Strength" } + + /// Smudge's slot `strength` drives the smudge distance (applied by the stage as + /// `smudge_radius_log`), not dab opacity — dabs always composite fully. + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + let mut p = super::default_brush_params(self.brush_kind(), s); + p.opacity = 1.0; + p } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs index a410810..7af9486 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SpongeTool; pub static SPONGE: SpongeTool = SpongeTool; @@ -8,19 +8,11 @@ pub static SPONGE: SpongeTool = SpongeTool; impl RasterToolDef for SpongeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } fn header_label(&self) -> &'static str { "Sponge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.sponge_radius, - opacity: s.sponge_flow, - hardness: s.sponge_hardness, - spacing: s.sponge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Sponge } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.sponge_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Flow" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for SpongeTool { s.sponge_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Flow:"); - ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs new file mode 100644 index 0000000..b67984c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs @@ -0,0 +1,102 @@ +//! Shared color swatch + picker popup used by the toolbar FG/BG swatches and the +//! gradient-stop editor. +//! +//! egui's default popup close behavior is `CloseOnClick`, which closes on a click *anywhere* — +//! including on the picker's own hue/alpha bars. And `CloseOnClickOutside` only reacts to a +//! click (press+release without movement), so painting a stroke on the stage — a drag — would +//! leave the popup open. Both call sites want the same thing, so it lives here once. + +use eframe::egui; + +/// Show a color-picker popup anchored to `toggle_response`. +/// +/// `swatch_rect` is excluded from the close-on-press check so the press that opens the popup +/// doesn't immediately close it again. +/// +/// Returns true if the color changed this frame. +pub fn color_picker_popup( + ui: &mut egui::Ui, + popup_id: egui::Id, + toggle_response: &egui::Response, + color: &mut egui::Color32, + swatch_rect: egui::Rect, +) -> bool { + let mut changed = false; + + let popup_response = egui::containers::Popup::from_toggle_button_response(toggle_response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.spacing_mut().slider_width = 275.0; + changed = egui::color_picker::color_picker_color32( + ui, + color, + egui::color_picker::Alpha::OnlyBlend, + ); + }); + + // Close on any pointer *press* outside the popup, not just a click, so that starting a + // brush stroke on the stage dismisses the picker. + if let Some(popup) = &popup_response { + let popup_rect = popup.response.rect; + let pressed_outside = ui.ctx().input(|i| { + i.pointer.any_pressed() + && i.pointer + .interact_pos() + .is_some_and(|p| !popup_rect.contains(p) && !swatch_rect.contains(p)) + }); + if pressed_outside { + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + + changed +} + +/// A color button (checkerboard under the color, so alpha reads correctly) that opens a color +/// picker popup when clicked. Returns true if the color changed this frame. +pub fn color_swatch( + ui: &mut egui::Ui, + id: egui::Id, + rect: egui::Rect, + color: &mut egui::Color32, +) -> bool { + let response = ui.interact(rect, id, egui::Sense::click()); + draw_color_button(ui, rect, *color); + color_picker_popup(ui, id.with("picker_popup"), &response, color, rect) +} + +/// Draw a color button with a checkerboard background so the alpha channel is visible. +pub fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { + let checker_size = 5.0; + let cols = (rect.width() / checker_size).ceil() as usize; + let rows = (rect.height() / checker_size).ceil() as usize; + + for row in 0..rows { + for col in 0..cols { + let is_light = (row + col) % 2 == 0; + let checker_color = if is_light { + egui::Color32::from_gray(180) + } else { + egui::Color32::from_gray(120) + }; + let checker_rect = egui::Rect::from_min_size( + egui::pos2( + rect.min.x + col as f32 * checker_size, + rect.min.y + row as f32 * checker_size, + ), + egui::vec2(checker_size, checker_size), + ) + .intersect(rect); + ui.painter().rect_filled(checker_rect, 0.0, checker_color); + } + } + + ui.painter().rect_filled(rect, 2.0, color); + ui.painter().rect_stroke( + rect, + 2.0, + egui::Stroke::new(1.0, egui::Color32::from_gray(80)), + egui::StrokeKind::Middle, + ); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs index 1e47eb5..0186941 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs @@ -1,6 +1,7 @@ //! Reusable UI widgets for the editor mod text_field; +pub mod color_swatch; pub mod dropdown_list; pub use text_field::ImeTextField; From 421f8fdcc69ac2913709e0d8693c76851ffdfab8 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 14 Jul 2026 13:42:01 -0400 Subject: [PATCH 11/11] Bump version to 1.0.10-alpha --- Changelog.md | 22 +++++++++++++++++++ .../lightningbeam-editor/Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Changelog.md b/Changelog.md index ef65494..f00dfdd 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,25 @@ +# 1.0.10-alpha: +Changes: +- Cycle recording: highlight a range on the timeline ruler (the cycle strip, shown when looping is armed) and the transport loops it. Recording round the loop turns each pass into a separate take you can choose between afterwards, and recording again over the same region adds more takes rather than stacking a second clip on top. Starting playback from outside the region jumps to its start +- Takes: a "Take N/M" badge on the clip picks which take plays. Splitting a clip lets each half play a different take, so you can comp a part together from the best bits of several passes. Right-click a clip to delete the take that's playing or to delete all the unused ones; double-click a take in the list to rename it +- MIDI cycle recording can either merge every pass into one clip (the default — earlier passes play back as you record, so you can layer a hi-hat over a kick) or keep each pass as a separate take, like audio does. Set it in Preferences → Audio +- Painting: every colour-using tool (brush, paint bucket, eyedropper) now shares one Foreground/Background swatch row, and the eyedropper has an explicit toggle for which of the two it fills +- Painting: the effect brushes (dodge/burn, sponge, blur, smudge, clone stamp, healing brush, pattern stamp) were stuck on a single fixed brush shape. They now each have their own size, strength, hardness and spacing, and can use any brush from the library +- Tablet: stylus barrel buttons now work, with actions you can bind in Preferences (Pan, Eyedropper, or Eraser). Middle-mouse panning added too +- Timeline: raster and text layers get an opacity slider; each layer row now shows only the controls that mean anything for it (volume for audio, opacity for raster/text, both for vector and video), and layers gain a visibility (eye) toggle +- The stage gains undo/redo buttons, sized for touch and tablet use + +Bugfixes: +- Splitting an audio clip made it play back at the wrong length (a clip split at 1 second cut off after half a second at 120 BPM) +- Trimmed MIDI clips were the wrong length on the timeline at any tempo other than 60 BPM +- The paint bucket always filled with the background colour, whatever the brush was set to +- Where an eyedropper sample landed (foreground or background) depended on which colour picker you had opened last +- Stylus barrel buttons were silently ignored on Wayland +- The opacity slider on a raster layer actually changed its volume, which means nothing on a raster layer +- Tool cursors lagged behind the pointer while drawing; they're now drawn by the system rather than into the canvas +- Colour picker popups wouldn't close when you dragged on the stage, so drawing left one hanging open, and clicking inside a picker's own hue slider closed it +- The toolbar could not be scrolled when the pane was too short to show every tool + # 1.0.9-alpha: Bugfixes: - Fix audio recording placement: a second recording landed at the wrong spot (and clicking/dragging clips was similarly off) at any tempo other than 60 BPM — recordings now start exactly at the playhead at any tempo diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index a24b1c6..f43b278 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightningbeam-editor" -version = "1.0.9-alpha" +version = "1.0.10-alpha" edition = "2021" description = "Multimedia editor for audio, video and 2D animation" license = "GPL-3.0-or-later"