From f418355cd2ab37404574c8634ca1fa0a6a12e109 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Sun, 26 Jul 2026 19:21:51 +0800 Subject: [PATCH 1/6] fix: clamp scrub voice reads to source bounds (EOF crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engaging a scrub with the playhead parked at end-of-track seeded the voice at total_frames — one past the last valid frame — and the first write_frame read indexed exactly source.len(), panicking on the audio callback thread. The panic can't unwind through coreaudio's extern "C" render callback, so the whole app aborted (SIGABRT). Guard write_frame's base index against the last frame and re-clamp the seeded position into the current source's range at the top of render / render_settle, so an EOF engage (or a snapshot swapped mid-gesture) holds the final sample instead of indexing out. Regression test seeds at exactly total_frames through both the render and settle paths. Also carries the in-progress elastic lead-in scrub changes that were already in the working copy of scrub.rs, which the clamps build on. Co-Authored-By: Claude Fable 5 --- crates/halo/src/scrub.rs | 265 ++++++++++++++++++++++++++++++++++----- 1 file changed, 234 insertions(+), 31 deletions(-) diff --git a/crates/halo/src/scrub.rs b/crates/halo/src/scrub.rs index 8f74a20..05389b4 100644 --- a/crates/halo/src/scrub.rs +++ b/crates/halo/src/scrub.rs @@ -27,15 +27,21 @@ const GATE_SMOOTH_SECS: f64 = 0.005; const SETTLE_TAU_SECS: f64 = 0.15; /// The glide is over once the rate is within this of its target. const SETTLE_EPS_RATE: f64 = 0.02; -/// Trajectory length cap in frames (safety; ~1.1 s suffices from ±32x). -const MAX_SETTLE_FRAMES: u64 = 48_000 * 5; +/// Trajectory length cap in seconds (safety): must cover a full lead-in on +/// a slow track traversed at half tempo, not just the rate convergence. +const MAX_SETTLE_SECS: f64 = 20.0; +/// Silent spring-back speed (source frames per output frame) returning a +/// paused release from the elastic lead-in to frame 0. +const SNAP_BACK_RATE: f64 = 16.0; /// Release-glide trajectory: the rate eases toward `rate_target` for /// exactly `frames_left` more frames (pre-counted at release so the landing -/// position is known in advance). +/// position is known in advance), then an optional silent spring-back rolls +/// a paused release out of the lead-in onto frame 0. struct SettleTraj { rate_target: f64, frames_left: u64, + snap_frames: u64, } /// Variable-rate scrub reader over an interleaved stereo source. @@ -46,10 +52,14 @@ pub struct ScrubVoice { rate: f64, /// Smoothed audibility gate, 0..1. gate: f64, + /// Position floor: 0.0, or `-lead_in` while an elastic lead-in gesture + /// is engaged. Positions below 0 read as silence. + min_pos: f64, catchup_frames: f64, rate_alpha: f64, gate_alpha: f64, settle_alpha: f64, + settle_cap_frames: u64, settle: Option, } @@ -60,14 +70,22 @@ impl ScrubVoice { pos: 0.0, rate: 0.0, gate: 0.0, + min_pos: 0.0, catchup_frames: (CATCHUP_SECS * sr).max(1.0), rate_alpha: 1.0 - (-1.0 / (RATE_SMOOTH_SECS * sr)).exp(), gate_alpha: 1.0 - (-1.0 / (GATE_SMOOTH_SECS * sr)).exp(), settle_alpha: 1.0 - (-1.0 / (SETTLE_TAU_SECS * sr)).exp(), + settle_cap_frames: (MAX_SETTLE_SECS * sr) as u64, settle: None, } } + /// Elastic lead-in depth for the current gesture; positions in + /// `[-frames, 0)` are draggable silence before the track start. + pub fn set_lead_in(&mut self, frames: f64) { + self.min_pos = -frames.max(0.0); + } + /// Re-anchor the reader at `frame` on scrub engage: no residual motion /// or gate from a previous gesture. pub fn seed(&mut self, frame: f64) { @@ -91,23 +109,43 @@ impl ScrubVoice { let total_frames = source.len() / CHANNELS; let max_pos = (total_frames.saturating_sub(1)) as f64; let mut rate = self.rate; - let mut pos = self.pos.clamp(0.0, max_pos); + let mut pos = self.pos.clamp(self.min_pos, max_pos); let mut n: u64 = 0; - while (rate - rate_target).abs() >= SETTLE_EPS_RATE && n < MAX_SETTLE_FRAMES { + // A playing release keeps simulating past rate convergence while + // still inside the silent lead-in, so the landing (= the engine + // handoff frame) is a real track frame and the voice audibly plays + // through frame 0. + while ((rate - rate_target).abs() >= SETTLE_EPS_RATE || (rate_target > 0.0 && pos < 0.0)) + && n < self.settle_cap_frames + { rate += (rate_target - rate) * self.settle_alpha; - pos = (pos + rate).clamp(0.0, max_pos); + pos = (pos + rate).clamp(self.min_pos, max_pos); n += 1; // A boundary ends the glide early — but only when still moving - // into it, so a clamped start can ease back off the rail. - if (pos == 0.0 && rate < 0.0) || (pos == max_pos && rate > 0.0) { + // into it, so a clamped start can ease back off the rail. The + // lead-in floor doesn't end a playing release: the spin-up + // pulls it back off. + if (pos == self.min_pos && rate < 0.0 && rate_target <= 0.0) + || (pos == max_pos && rate > 0.0) + { break; } } + // A paused release stranded in the lead-in gets a silent constant- + // rate spring-back that lands exactly on frame 0. + let snap_frames = if rate_target == 0.0 && pos < 0.0 { + (-pos / SNAP_BACK_RATE).ceil() as u64 + } else { + 0 + }; self.settle = Some(SettleTraj { rate_target, frames_left: n, + snap_frames, }); - pos + // `.max(0.0)`: if the cap ever truncates a glide inside the lead-in, + // report a real track frame anyway — the voice there is silent. + if snap_frames > 0 { 0.0 } else { pos.max(0.0) } } /// Render one glide block into `out` (interleaved stereo, overwritten). @@ -119,6 +157,7 @@ impl ScrubVoice { let Some(SettleTraj { rate_target, ref mut frames_left, + ref mut snap_frames, }) = self.settle else { out.fill(0.0); @@ -129,29 +168,39 @@ impl ScrubVoice { return true; } let max_pos = (total_frames - 1) as f64; + let min_pos = self.min_pos; + // Same guard as `render`: keep a stale position in this source's + // range (matches the clamped start `begin_settle` simulated from). + self.pos = self.pos.clamp(min_pos, max_pos); for frame in out.chunks_exact_mut(CHANNELS) { + if *frames_left == 0 && *snap_frames > 0 { + // Silent spring-back out of the lead-in: the gate stays + // shut while the position rolls onto the 0 rail — the + // `.min(0.0)` lands bit-exact on the reported landing. + self.rate += (rate_target - self.rate) * self.settle_alpha; + self.gate += (0.0 - self.gate) * self.gate_alpha; + frame.fill(0.0); + self.pos = (self.pos + SNAP_BACK_RATE).min(0.0); + *snap_frames -= 1; + continue; + } + // Same op order as the begin_settle simulation: rate, then // read at the pre-advance position, then advance + clamp. self.rate += (rate_target - self.rate) * self.settle_alpha; let gate_target = (self.rate.abs() / AUDIBLE_RATE).min(1.0); self.gate += (gate_target - self.gate) * self.gate_alpha; - let base = self.pos.floor() as usize; - let frac = (self.pos - base as f64) as f32; - let next = (base + 1).min(total_frames - 1); - let gain = self.gate as f32; - for (ch, sample) in frame.iter_mut().enumerate() { - let a = source[base * CHANNELS + ch]; - let b = source[next * CHANNELS + ch]; - *sample = (a + (b - a) * frac) * gain; - } + write_frame(self.pos, self.gate, source, total_frames, frame); - self.pos = (self.pos + self.rate).clamp(0.0, max_pos); + self.pos = (self.pos + self.rate).clamp(min_pos, max_pos); *frames_left = frames_left.saturating_sub(1); } - self.settle.as_ref().is_none_or(|s| s.frames_left == 0) + self.settle + .as_ref() + .is_none_or(|s| s.frames_left == 0 && s.snap_frames == 0) } /// Render one block into `out` (interleaved stereo, overwritten), @@ -163,7 +212,11 @@ impl ScrubVoice { return; } let max_pos = (total_frames - 1) as f64; - let target = target_frame.clamp(0.0, max_pos); + // The seeded position can sit outside this source's range (EOF + // playhead, or the snapshot swapped mid-gesture); re-enter range + // before the first read. + self.pos = self.pos.clamp(self.min_pos, max_pos); + let target = target_frame.clamp(self.min_pos, max_pos); let target_rate = ((target - self.pos) / self.catchup_frames).clamp(-MAX_RATE, MAX_RATE); for frame in out.chunks_exact_mut(CHANNELS) { @@ -171,21 +224,36 @@ impl ScrubVoice { let gate_target = (self.rate.abs() / AUDIBLE_RATE).min(1.0); self.gate += (gate_target - self.gate) * self.gate_alpha; - let base = self.pos.floor() as usize; - let frac = (self.pos - base as f64) as f32; - let next = (base + 1).min(total_frames - 1); - let gain = self.gate as f32; - for (ch, sample) in frame.iter_mut().enumerate() { - let a = source[base * CHANNELS + ch]; - let b = source[next * CHANNELS + ch]; - *sample = (a + (b - a) * frac) * gain; - } + write_frame(self.pos, self.gate, source, total_frames, frame); - self.pos = (self.pos + self.rate).clamp(0.0, max_pos); + self.pos = (self.pos + self.rate).clamp(self.min_pos, max_pos); } } } +/// Gated linear-interpolated read of `source` at `pos` into one output +/// frame. Positions before the track start (the elastic lead-in) read as +/// silence — never indexed. +#[inline] +fn write_frame(pos: f64, gate: f64, source: &[f32], total_frames: usize, frame: &mut [f32]) { + if pos < 0.0 { + frame.fill(0.0); + return; + } + // A position at or past the last frame holds the final sample: the + // published playhead is `total_frames` at EOF, so a scrub seeded there + // starts one whole frame beyond the last valid index. + let base = (pos.floor() as usize).min(total_frames - 1); + let frac = (pos - base as f64) as f32; + let next = (base + 1).min(total_frames - 1); + let gain = gate as f32; + for (ch, sample) in frame.iter_mut().enumerate() { + let a = source[base * CHANNELS + ch]; + let b = source[next * CHANNELS + ch]; + *sample = (a + (b - a) * frac) * gain; + } +} + #[cfg(test)] mod tests { use super::*; @@ -251,6 +319,27 @@ mod tests { assert!(voice.pos <= 512.0 * MAX_RATE); } + /// Regression: the EOF playhead publishes `total_frames` — one past the + /// last valid frame — and a scrub engaged there used to index out of + /// bounds on the first read (crash observed live at scrub.rs:241). + #[test] + fn seed_at_eof_reads_safely() { + let frames = 1_000; + let source = ramp_source(frames); + let mut voice = ScrubVoice::new(SR); + voice.seed(frames as f64); + let mut out = vec![0.0f32; 64 * CHANNELS]; + voice.render(frames as f64, &source, &mut out); + assert!(voice.pos <= (frames - 1) as f64); + + // Same engage point, released while paused: the settle path must + // hold the clamp too. + voice.seed(frames as f64); + voice.begin_settle(0.0, &source); + voice.render_settle(&source, &mut out); + assert!(voice.pos <= (frames - 1) as f64); + } + #[test] fn position_clamps_at_boundaries() { let source = ramp_source(1_000); @@ -416,6 +505,120 @@ mod tests { assert_eq!(voice.pos, landing); } + #[test] + fn lead_in_floors_at_configured_depth() { + let source = ramp_source(1_000); + let mut voice = ScrubVoice::new(SR); + voice.seed(500.0); + voice.set_lead_in(4_800.0); + render_blocks(&mut voice, -100_000.0, &source, 200); + assert!( + (voice.pos + 4_800.0).abs() < 1.0, + "pos {} should floor at -4800", + voice.pos + ); + } + + #[test] + fn lead_in_renders_silence_while_moving() { + let source = vec![1.0f32; 10_000 * CHANNELS]; // constant full-scale + let mut voice = ScrubVoice::new(SR); + voice.seed(2_000.0); + voice.set_lead_in(48_000.0); + // Chase deep into the lead-in but stop while still moving fast, so + // the gate is open and only the pos < 0 read keeps the output silent. + render_blocks(&mut voice, -40_000.0, &source, 3); + assert!( + voice.pos < -1_000.0, + "pos {} should be in lead-in", + voice.pos + ); + assert!(voice.rate.abs() > AUDIBLE_RATE, "gate must be open"); + let mut out = vec![1.0f32; 256 * CHANNELS]; + voice.render(-40_000.0, &source, &mut out); + assert!( + out.iter().all(|&s| s == 0.0), + "lead-in must read as silence" + ); + } + + #[test] + fn release_playing_from_lead_in_lands_in_track() { + let source = ramp_source(SR as usize * 30); + let mut voice = ScrubVoice::new(SR); + voice.set_lead_in(48_000.0); + voice.seed(-30_000.0); // held in the lead-in, rate 0 + let landing = voice.begin_settle(1.0, &source); + assert!( + landing >= 0.0, + "playing release must land in the track, got {landing}" + ); + // Render exactly the trajectory length in odd-sized blocks; track + // that the spin-up rolls monotonically forward through frame 0. + let traj_frames = voice.settle.as_ref().unwrap().frames_left; + let mut remaining = traj_frames as usize; + let mut out = vec![0.0f32; 173 * CHANNELS]; + let mut prev_pos = voice.pos; + while remaining >= 173 { + voice.render_settle(&source, &mut out); + assert!(voice.pos >= prev_pos, "spin-up must not roll backward"); + prev_pos = voice.pos; + remaining -= 173; + } + let mut tail = vec![0.0f32; remaining * CHANNELS]; + if remaining > 0 { + voice.render_settle(&source, &mut tail); + } + assert_eq!( + voice.pos, landing, + "must land bit-exactly on the prediction" + ); + assert!((voice.rate - 1.0).abs() < SETTLE_EPS_RATE + 1e-9); + } + + #[test] + fn release_paused_from_lead_in_springs_back_to_zero() { + let source = vec![1.0f32; 10_000 * CHANNELS]; + let mut voice = ScrubVoice::new(SR); + voice.set_lead_in(48_000.0); + voice.seed(-20_000.0); // held in the lead-in, rate 0 + let landing = voice.begin_settle(0.0, &source); + assert_eq!(landing, 0.0, "paused release springs back to the start"); + let mut out = vec![1.0f32; 512 * CHANNELS]; + for _ in 0..2_000 { + let done = voice.render_settle(&source, &mut out); + assert!( + out.iter().all(|&s| s == 0.0), + "spring-back must stay silent" + ); + if done { + assert_eq!(voice.pos, 0.0, "spring-back lands exactly on frame 0"); + return; + } + } + panic!("spring-back never completed"); + } + + #[test] + fn settle_cap_covers_full_bar_lead_in() { + let source = ramp_source(SR as usize * 30); + let mut voice = ScrubVoice::new(SR); + let lead_in = 4.0 * SR as f64; // far deeper than any real lead-in + voice.set_lead_in(lead_in); + voice.seed(-lead_in); + // Half tempo: the slowest realistic traversal of the full lead-in. + let landing = voice.begin_settle(0.5, &source); + let traj = voice.settle.as_ref().unwrap().frames_left; + assert!( + landing >= 0.0, + "cap must not strand the landing, got {landing}" + ); + assert!( + traj < voice.settle_cap_frames, + "trajectory {traj} hit the cap" + ); + } + #[test] fn spin_down_fades_to_silence() { let source = vec![1.0f32; SR as usize * 20 * CHANNELS]; From 22b63b1a05519cc79ebda719958b3d7a64ccb6b4 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 27 Jul 2026 06:42:36 +0800 Subject: [PATCH 2/6] Deck UI restructure + auto-cue Deck UI: per-deck right sidebar (BPM, keylock, master/sync, pitch fader restyled with a chunky cap and dense tick ladder), captioned control groups (transport/nudge/loop/hot-cues/gate/quantize), a key badge in the header, single elapsed/remaining readout with a toggle, bar numbers and red cue markers on the zoomed view, bar numbers on the overview, and a left label gutter for the LGT/PXL/FX lanes. Also bundles the in-progress auto-cue feature (park the deck at the first downbeat on load, persisted per deck) and the related state.rs/audio.rs changes that were pending in the working tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 2 +- ROADMAP.md | 88 ++- crates/halo/src/app.rs | 1068 ++++++++++++++++---------- crates/halo/src/audio.rs | 1 + crates/halo/src/fader.rs | 60 +- crates/halo/src/state.rs | 19 +- crates/halo/src/waveform/lanes.rs | 48 +- crates/halo/src/waveform/mod.rs | 31 +- crates/halo/src/waveform/overview.rs | 35 +- crates/halo/src/waveform/zoomed.rs | 108 +++ 10 files changed, 994 insertions(+), 466 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8156a37..4fe2ce3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3942,7 +3942,7 @@ dependencies = [ [[package]] name = "timestretch" -version = "0.8.0" +version = "0.8.1" dependencies = [ "arc-swap", "rustfft", diff --git a/ROADMAP.md b/ROADMAP.md index 03f8a48..a71c24d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -225,7 +225,9 @@ persistence shell don't change; bump `CueFile.version` as fields grow): 1. **Per-cue fade in/out** — crossfade the outgoing cue into the incoming one inside `resolve()`. Cues stay non-overlapping in the data; this covers the common "new look starts while the old is still visible" - case with minimal machinery. + case with minimal machinery. (Phase L3's look lane makes same-lane + overlap inexpressible by construction — if L3 lands first, this step + reduces to fades at look-event boundaries.) 2. **Per-fixture cue targets** — cues carry a fixture selection; relax the invariant from "no time overlap per lane" to "no overlap per fixture", so cues that touch disjoint fixtures may overlap freely @@ -253,39 +255,71 @@ override *replaces* a lane with a flat level rather than attenuating the authored shape; and cues attach to tracks globally, so a track lights identically in every set and "skip" has no non-destructive gesture. -Build order is smallest-first; every step keeps `resolve()` as the single -merge point and `render()` pure: - -1. **Energy master** — a global 0–100% fader scaling resolved track-cue - levels multiplicatively inside `resolve()`; the priority stack becomes - Programmer (replace) > energy (scale) > track cues > off. Authored - shape is preserved — builds and chases just sit lower — and - programmer overrides are unaffected. Optionally damp strobe and - effect rate when energy drops below a threshold. Small, and - immediately useful live. -2. **Looks in cues** — a `Look` is a stored snapshot of programmer - params (STORE-from-live already captures one), persisted by id in the - library. `Cue` gains an optional `look_id`; `resolve()` yields level - + look per lane and `render()` uses the cue's look instead of the - global live params, falling back to today's behavior for look-less - cues. Bump `CueFile.version`. Dovetails with Phase L2's fades: - crossfade between looks, not just levels. +**Re-base the lanes from fixture taxonomy to roles.** Today's +Lighting / Pixels / FX lanes are keyed by `FixtureKind` — a hardware +axis, when authoring thinks in intent ("the drop hits: everything red, +strobe chase"). One musical moment smears across three lanes kept in +sync by hand, and an intensity bar conflates *that* something happens +with *what* happens. The show model decomposes into what / how much / +when, so the lanes become: + +- **Look lane** — sparse, beat-snapped *events*: each switches the rig + to a stored look and holds until the next event (blocks tinted by the + look's color — the strip reads as the track's color script). A look + contains palette, position, and effects, so one event replaces three + coordinated bars; per-fixture-kind intensity moves *inside* looks, + where a console would put it anyway. +- **Energy lane** — a drawn envelope (automation-style breakpoints, not + bars): the narrative arc made directly editable, multiplying whatever + the look outputs. +- **Accent lane** — momentary one-shots (strobe hit, blinder, pyro): + the only items needing bar-precise start *and* end, and exactly the + set to arm/disarm live. Today's lane semantics survive here intact. + +The `CueSet` machinery (windowed queries, sorted-lane invariants, +painters, drag editor, JSON persistence) is semantics-agnostic — this +re-labels the axis rather than rebuilding the editor. Look events are +cues with implicit duration; only the energy curve is a new item type. +Duration-until-next removes same-lane overlap by construction, which +supersedes Phase L2 step 1 for the look lane; HTP/LTP (L2 step 3) still +governs accents firing over the active look. + +Build order is smallest-first; every step keeps `resolve()` as the +single merge point and `render()` pure: + +1. **Energy** — the authored curve, plus a live master fader that + scales/offsets it; both multiply resolved output inside `resolve()`. + The priority stack becomes Programmer (replace) > energy (scale) > + track cues > off. Authored shape is preserved — builds and chases + just sit lower. Optionally damp strobe and effect rate when energy + drops below a threshold. The fader alone is small and immediately + useful live; the curve lands with the lane re-base. +2. **Look lane** — a `Look` is a stored snapshot of programmer params + (STORE-from-live already captures one), persisted by id in the + library. `resolve()` yields (active look, energy, accents) instead + of three levels; `render()` renders the cue's look instead of the + global live params. Bump `CueFile.version` with a migration from + the three intensity lanes. Crossfade between looks at event + boundaries (Phase L2 step 1's fade machinery, applied here). 3. **Show entity** — a `Show` is a playlist plus per-entry deltas: - non-destructive per-cue arm/disarm (click a cue bar hollow to skip it - tonight), and an optional per-entry energy/theme override so the same - track can sit differently in different sets. Track cues stay the - authored default; the show stores only deltas (new tables alongside - `playlists`). + non-destructive arm/disarm on look events and accents (click a bar + hollow to skip it tonight), and an optional per-entry energy/theme + override so the same track can sit differently in different sets. + Track cues stay the authored default; the show stores only deltas + (new tables alongside `playlists`). + +Live UX falls out directly: tap a look event to jump or skip, one fader +pulls the arc down, disarm an accent — nothing destructive. Open question to resolve here: lighting follows a single deck's playhead, so during a two-deck blend the look hard-switches when the lighting deck changes — decide whether lighting should follow the audio crossfader once shows span transitions. -**Milestone:** run a playlist as a show — looks fire from track cues -through the arc, one fader pulls the whole rig to 60% when the room -isn't there, and tonight's skipped sequence never touches the authored -cues. +**Milestone:** run a playlist as a show — the look lane plays the +track's color script, the energy curve draws the arc, one fader pulls +the whole rig to 60% when the room isn't there, and tonight's skipped +accent never touches the authored cues. --- diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index 0a4ad59..a3434c3 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -23,9 +23,9 @@ use crate::programmer_ui::{ProgrammerCtx, programmer_panel}; use crate::show::simulate_show; use crate::state::{MixerShared, ScrubPhase, Transport}; use crate::waveform::{ - BandPeaks, EditorInteraction, GridMarks, LanesEditorParams, LanesParams, OverviewParams, - OverviewTexture, ScrubGesture, ZoomSpan, ZoomedParams, lanes_editor, paint_beat_counter, - paint_lanes, paint_overview, paint_zoomed, + BandPeaks, EditorInteraction, GhostPlayhead, GridMarks, LanesEditorParams, LanesParams, + OverviewParams, OverviewTexture, ScrubGesture, ZoomSpan, ZoomedParams, lanes_editor, + paint_beat_counter, paint_lanes, paint_overview, paint_zoomed, }; use crate::worker::{WorkerEvent, spawn_analysis_worker, spawn_folder_import}; @@ -54,6 +54,43 @@ struct LoadedData { type DecodeResult = Result; +/// Ghost-playhead slide-in duration after a sync-aligned play start. +const GHOST_ANIM_SECS: f32 = 0.4; +/// Skip the ghost when the align jump is smaller than this (source frames); +/// a sub-beat sliver would just flicker under the playhead. +const GHOST_MIN_DELTA_FRAMES: f64 = 256.0; + +/// Sync-aligned play start animation: the pre-align playhead position +/// gliding into the centered playhead. +struct GhostAnim { + /// Pre-align playhead minus the aligned seek target (source frames). + delta_frames: f64, + started: std::time::Instant, +} + +impl GhostAnim { + fn new(delta_frames: f64) -> Self { + Self { + delta_frames, + started: std::time::Instant::now(), + } + } + + fn finished(&self) -> bool { + self.started.elapsed().as_secs_f32() >= GHOST_ANIM_SECS + } + + /// Cubic ease-out slide toward offset 0, linear fade. + fn params(&self) -> GhostPlayhead { + let t = (self.started.elapsed().as_secs_f32() / GHOST_ANIM_SECS).clamp(0.0, 1.0); + let ease = 1.0 - (1.0 - t).powi(3); + GhostPlayhead { + offset_frames: self.delta_frames * (1.0 - f64::from(ease)), + alpha: (1.0 - t) * 0.9, + } + } +} + struct DeckUi { deck: Deck, decode_rx: Option>, @@ -77,8 +114,13 @@ struct DeckUi { zoom: ZoomSpan, /// Pointer-implied platter position while the zoomed waveform is /// dragged (None = not dragging); published to the audio callback's - /// scrub voice as the chase target. + /// scrub voice as the chase target. Dips below 0 in the elastic + /// lead-in. scrub_pos: Option, + /// Elastic lead-in depth captured at Grab, so the drag clamp matches + /// exactly the floor the audio voice saw even if the grid re-analyzes + /// mid-gesture. + scrub_lead_in: f64, /// Last consumed scrub-landing sequence number; each newly published /// landing fires one parallel engine warm-start seek. landing_seq_seen: u64, @@ -99,6 +141,8 @@ struct DeckUi { /// while synced and both decks play. Filters playhead-publish jitter so /// the sync PLL doesn't chase phantom errors. phase_err: Option, + /// Ghost-playhead slide-in running after a sync-aligned jump. + ghost: Option, /// Hot cue slots (source frames). hot_cues: [Option; 8], hotcue_was_down: [bool; 8], @@ -108,6 +152,11 @@ struct DeckUi { gated_held: Option, /// Quantize hot cues and loop points to the beat grid. quantize: bool, + /// Auto cue: on load, park the deck at the first downbeat. + auto_cue: bool, + /// Cue frame last set by auto cue; guards re-application when async + /// analysis refines the grid. + last_auto_cue: Option, /// Header time readout shows remaining (true) or elapsed (false). show_remaining: bool, /// Staged loop-in point awaiting loop-out. @@ -137,6 +186,7 @@ impl DeckUi { artist: None, zoom: ZoomSpan::default(), scrub_pos: None, + scrub_lead_in: 0.0, landing_seq_seen: 0, cue_was_down: false, cue_previewing: false, @@ -146,11 +196,14 @@ impl DeckUi { synced: false, bend: 1.0, phase_err: None, + ghost: None, hot_cues: [None; 8], hotcue_was_down: [false; 8], gated: false, gated_held: None, quantize: true, + auto_cue: true, + last_auto_cue: None, show_remaining: false, loop_in_staged: None, loop_beats: 4.0, @@ -236,16 +289,16 @@ impl DeckUi { } } - /// Quantized 4-beat autoloop at the current position. - fn autoloop_4(&mut self) { + /// Quantized autoloop of `beats` beats at the current position. + fn autoloop(&mut self, beats: f64) { if self.deck.track.is_none() || !self.marks.is_usable() { return; } let start = quantize_frame(&self.marks, true, self.playhead()); - let end = loop_end_for(&self.marks, start, 4.0); + let end = loop_end_for(&self.marks, start, beats); if end > start { self.deck.shared.set_loop(Some((start, end))); - self.loop_beats = 4.0; + self.loop_beats = beats; self.loop_in_staged = None; } } @@ -310,6 +363,9 @@ struct Persisted { /// Inverted so the missing-field default (false) means snap ON. #[serde(default)] snap_off: bool, + /// Inverted so the missing-field default (false) means auto cue ON. + #[serde(default)] + auto_cue_off: [bool; 2], #[serde(default)] footer_tab: FooterTab, } @@ -382,7 +438,10 @@ struct PrepareState { impl PrepareState { fn new() -> Self { - let audition = DeckUi::new(); + let mut audition = DeckUi::new(); + // No auto cue for previews: they start at the top of the file, and + // the Prepare UI has no toggle for it. + audition.auto_cue = false; audition.deck.shared.fader.store(AUDITION_VOLUME_DEFAULT); Self { audition, @@ -498,6 +557,7 @@ impl HaloApp { deck_ui.keylock = persisted.keylocks[i]; deck_ui.pitch_range = persisted.pitch_ranges[i].max(8.0); deck_ui.quantize = persisted.quantize[i]; + deck_ui.auto_cue = !persisted.auto_cue_off[i]; deck_ui.gated = persisted.gated[i]; } } @@ -847,6 +907,23 @@ impl HaloApp { if data.artifact.is_none() && data.track_id.is_some() { let _ = wake_tx.send(()); } + // Auto cue: park the deck at the first downbeat. + // Ceil so the parked position sits at/after the + // grid frame and the bar readout says 1.1, not 0.4. + deck_ui.last_auto_cue = None; + if deck_ui.auto_cue + && deck_ui.marks.is_usable() + && let Some(frame) = deck_ui.marks.first_downbeat_frame() + { + let frame = frame.ceil() as usize; + deck_ui + .deck + .shared + .cue_point + .store(frame as u64, Ordering::Relaxed); + deck_ui.deck.shared.request_seek(frame); + deck_ui.last_auto_cue = Some(frame); + } // Device-change reload: restore the playhead. if let Some(frac) = deck_ui.pending_seek_frac.take() { let total = deck_ui.deck.shared.total(); @@ -902,6 +979,25 @@ impl HaloApp { deck_ui.marks = GridMarks::from_grid(&grid_from_artifact(&resampled)); deck_ui.bpm = resampled.bpm; deck_ui.pending_artifact = Some(Arc::new(resampled)); + // The refined grid may move the first downbeat: + // re-apply auto cue, but never disturb a playing + // or scrubbing deck, a user-moved cue, or a + // user-moved playhead. + let shared = &deck_ui.deck.shared; + if deck_ui.auto_cue + && shared.transport() != Transport::Playing + && deck_ui.scrub_pos.is_none() + && let Some(prev) = deck_ui.last_auto_cue + && shared.cue_point.load(Ordering::Relaxed) as usize == prev + && let Some(frame) = deck_ui.marks.first_downbeat_frame() + { + let frame = frame.ceil() as usize; + shared.cue_point.store(frame as u64, Ordering::Relaxed); + if shared.playhead_frames() == prev { + shared.request_seek(frame); + } + deck_ui.last_auto_cue = Some(frame); + } } } if let Some(lib) = &self.library @@ -1130,10 +1226,10 @@ impl HaloApp { for d in 0..2 { let prev = self.kb_prev[d]; let now = down[d]; - let deck_ui = &mut self.decks[d]; if now[0] && !prev[0] { - deck_ui.toggle_play(); + self.toggle_play_synced(d); } + let deck_ui = &mut self.decks[d]; if now[1] && !prev[1] { deck_ui.cue_press(); } @@ -1141,7 +1237,7 @@ impl HaloApp { deck_ui.cue_release(); } if now[2] && !prev[2] { - deck_ui.autoloop_4(); + deck_ui.autoloop(4.0); } if now[3] && !prev[3] { deck_ui.exit_loop(); @@ -1829,14 +1925,17 @@ impl HaloApp { let shared = audition.deck.shared.clone(); let has_track = audition.deck.track.is_some(); let total = shared.total(); - // Scrub-aware position, same as deck_panel. - let playhead = if let Some(pos) = audition.scrub_pos { - pos.clamp(0.0, total as f64) as usize + // Scrub-aware signed position, same as deck_panel: dips below 0 in + // the elastic lead-in. Only the zoomed waveform + lanes editor see + // the sign; every other consumer uses the clamped `playhead`. + let display_pos = if let Some(pos) = audition.scrub_pos { + pos.min(total as f64) } else if shared.scrub.phase() == ScrubPhase::Settling { - shared.scrub.voice_frame().clamp(0.0, total as f64) as usize + shared.scrub.voice_frame().min(total as f64) } else { - shared.playhead_frames().min(total) + shared.playhead_frames().min(total) as f64 }; + let playhead = display_pos.max(0.0) as usize; let playing = shared.transport() == Transport::Playing; ui.add_space(8.0); @@ -1930,7 +2029,6 @@ impl HaloApp { // Waveform stack — same painters as a deck, full width. let loop_region = shared.loop_region(); - let display_pos = playhead as f64; let gesture = paint_zoomed( ui, ZoomedParams { @@ -1941,10 +2039,21 @@ impl HaloApp { sample_rate, loop_region, loop_in: audition.loop_in_staged, + hot_cues: &[], + cue_point: has_track.then(|| shared.cue_point.load(Ordering::Relaxed) as usize), + ghost: None, }, &mut audition.zoom, ); - handle_scrub_gesture(audition, gesture, has_track, has_audio, playing, playhead); + handle_scrub_gesture( + audition, + gesture, + has_track, + has_audio, + playing, + display_pos, + sample_rate, + ); ui.add_space(2.0); let mut mutated = lanes_editor( @@ -2069,6 +2178,7 @@ impl HaloApp { loop_region, loop_in: audition.loop_in_staged, hot_cues: &[], + marks: &audition.marks, }, ) && has_track { @@ -2209,8 +2319,54 @@ impl HaloApp { // A scrub glide animates the playhead even while paused, // so it keeps the repaint loop alive too. || d.deck.shared.scrub.phase() != ScrubPhase::Idle + // A ghost playhead keeps fading even if the deck was + // paused right after the sync-aligned start. + || d.ghost.is_some() }) } + + /// Play/pause for deck `i`. When starting a synced non-master deck while + /// the master plays, seek to the nearest phase-aligned frame first so the + /// audio starts on beat, and kick off the ghost-playhead slide-in. + fn toggle_play_synced(&mut self, i: usize) { + let d = &self.decks[i]; + let starting = d.deck.track.is_some() && d.deck.shared.transport() != Transport::Playing; + let aligned_start = starting + && d.synced + && i != self.master + // Don't fight a scrub glide's own landing seek. + && d.deck.shared.scrub.phase() == ScrubPhase::Idle + && self.decks[self.master].deck.shared.transport() == Transport::Playing; + if !aligned_start { + self.decks[i].toggle_play(); + return; + } + let m = &self.decks[self.master]; + let master_phase = beat_phase(&m.marks, m.playhead() as f64); + let d = &self.decks[i]; + let total = d.deck.shared.total(); + // Mirror toggle_play's EOF rewind: align as if starting from zero. + let from = if total > 0 && d.playhead() >= total { + 0.0 + } else { + d.playhead() as f64 + }; + let target = master_phase.and_then(|mp| align_target_frame(&d.marks, from, mp, total)); + let Some(target) = target else { + // No usable grid on one of the decks: plain start, no jump. + self.decks[i].toggle_play(); + return; + }; + let d = &mut self.decks[i]; + d.cue_previewing = false; + request_seek_guarded(&d.deck.shared, target); + d.deck.shared.set_transport(Transport::Playing); + // The jump invalidates any smoothed error history (same as engaging + // sync does). + d.phase_err = None; + let delta = from - target as f64; + d.ghost = (delta.abs() >= GHOST_MIN_DELTA_FRAMES).then(|| GhostAnim::new(delta)); + } } impl eframe::App for HaloApp { @@ -2236,6 +2392,7 @@ impl eframe::App for HaloApp { view: self.view, audition_volume: self.prepare.audition.deck.shared.fader.load(), snap_off: !self.prepare.snap, + auto_cue_off: [!self.decks[0].auto_cue, !self.decks[1].auto_cue], footer_tab: self.footer_tab, }, ); @@ -2245,12 +2402,21 @@ impl eframe::App for HaloApp { // Scrub glide bookkeeping: each newly published landing fires the // parallel engine warm-start, so the engine is primed at the // predicted frame by the time the glide hands back to it. - for deck_ui in &mut self.decks { + for deck_ui in self + .decks + .iter_mut() + .chain(std::iter::once(&mut self.prepare.audition)) + { let shared = &deck_ui.deck.shared; let (landing_seq, landing) = shared.scrub.landing(); if landing_seq != deck_ui.landing_seq_seen { deck_ui.landing_seq_seen = landing_seq; - request_seek_guarded(shared, landing as usize); + // `.max(0.0)`: a landing is >= 0 by contract, but a negative + // f64 cast to usize would wrap into a garbage seek. + request_seek_guarded(shared, landing.max(0.0) as usize); + } + if deck_ui.ghost.as_ref().is_some_and(GhostAnim::finished) { + deck_ui.ghost = None; } } self.poll_decodes(ctx); @@ -2510,6 +2676,9 @@ impl eframe::App for HaloApp { // The master leads; it can't also follow. self.decks[i].synced = false; } + if resp.play_toggled { + self.toggle_play_synced(i); + } // Engaging sync jumps straight onto the master's beat (at // most half a beat, the short way); the PLL holds the lock // from there. @@ -2518,13 +2687,16 @@ impl eframe::App for HaloApp { let master_phase = beat_phase(&m.marks, m.playhead() as f64); if let Some(mp) = master_phase { let d = &self.decks[i]; - if let Some(target) = align_target_frame( - &d.marks, - d.playhead() as f64, - mp, - d.deck.shared.total(), - ) { + let from = d.playhead() as f64; + if let Some(target) = + align_target_frame(&d.marks, from, mp, d.deck.shared.total()) + { request_seek_guarded(&d.deck.shared, target); + // Same visual jump as an aligned play start, so + // it gets the same ghost slide-in. + let delta = from - target as f64; + self.decks[i].ghost = (delta.abs() >= GHOST_MIN_DELTA_FRAMES) + .then(|| GhostAnim::new(delta)); } } // The jump invalidates any smoothed error history. @@ -2985,6 +3157,9 @@ struct DeckPanelResponse { load_track_id: Option, master_clicked: bool, sync_engaged: bool, + /// Play/pause was pressed; handled at app level so a synced start can + /// beat-align against the master deck first. + play_toggled: bool, } /// Three always-visible dots answering "what is the rig doing right now": @@ -3007,29 +3182,45 @@ fn lighting_leds(ui: &mut egui::Ui, outputs: &[LaneOutput; LANE_COUNT]) { /// Apply a zoomed-waveform drag gesture to a deck's scrub state: grab the /// platter, chase the hand while dragging, release into a momentum glide /// (shared by the performance decks and the Prepare audition player). +/// Elastic lead-in depth for platter scrubs: one beat, or ~0.5 s of frames +/// when the track has no usable grid. +fn scrub_lead_in(marks: &GridMarks, sample_rate: u32) -> f64 { + let beat = marks.median_beat_frames(); + if beat > 0.0 { + beat + } else { + 0.5 * sample_rate as f64 + } +} + fn handle_scrub_gesture( deck_ui: &mut DeckUi, gesture: Option, has_track: bool, has_audio: bool, playing: bool, - playhead: usize, + grab_pos: f64, + sample_rate: u32, ) { let shared = deck_ui.deck.shared.clone(); let total = shared.total(); match gesture { Some(ScrubGesture::Grab) => { if has_track && total > 0 { - // `playhead` is scrub-aware at the caller, so re-grabbing a + // `grab_pos` is scrub-aware at the caller, so re-grabbing a // mid-glide platter continues from the voice's gliding - // position, not the stale engine playhead. - deck_ui.scrub_pos = Some(playhead as f64); - shared.scrub.begin(playhead as f64); + // position — including inside the lead-in — not the stale + // engine playhead. + let lead_in = scrub_lead_in(&deck_ui.marks, sample_rate); + deck_ui.scrub_lead_in = lead_in; + deck_ui.scrub_pos = Some(grab_pos); + shared.scrub.begin(grab_pos, lead_in); } } Some(ScrubGesture::Drag(delta)) => { if let Some(pos) = deck_ui.scrub_pos { - let target = (pos + delta).clamp(0.0, total.saturating_sub(1) as f64); + let target = + (pos + delta).clamp(-deck_ui.scrub_lead_in, total.saturating_sub(1) as f64); shared.scrub.update_target(target); deck_ui.scrub_pos = Some(target); } @@ -3050,7 +3241,7 @@ fn handle_scrub_gesture( } else { // No audio stream to render a glide — land instantly. shared.scrub.cancel(); - request_seek_guarded(&shared, frame as usize); + request_seek_guarded(&shared, frame.max(0.0) as usize); } } } @@ -3062,7 +3253,7 @@ fn handle_scrub_gesture( fn deck_panel( ui: &mut egui::Ui, deck_ui: &mut DeckUi, - _idx: usize, + idx: usize, sample_rate: u32, is_master: bool, // Some exactly when this deck drives the lighting rig. @@ -3080,14 +3271,17 @@ fn deck_panel( // Scrub-aware position: during a drag the UI owns the displayed // position (the hand target); during the release glide the audio // callback's voice does. Everything downstream — waveform, overview, - // time readouts, quantized cues — follows the platter. - let playhead = if let Some(pos) = deck_ui.scrub_pos { - pos.clamp(0.0, total as f64) as usize + // time readouts, quantized cues — follows the platter. `display_pos` + // keeps the sign (it dips below 0 in the elastic lead-in) for the + // waveform painters; every other consumer uses the clamped `playhead`. + let display_pos = if let Some(pos) = deck_ui.scrub_pos { + pos.min(total as f64) } else if shared.scrub.phase() == ScrubPhase::Settling { - shared.scrub.voice_frame().clamp(0.0, total as f64) as usize + shared.scrub.voice_frame().min(total as f64) } else { - shared.playhead_frames().min(total) + shared.playhead_frames().min(total) as f64 }; + let playhead = display_pos.max(0.0) as usize; let transport = shared.transport(); let playing = transport == Transport::Playing; // Display rate: slider pitch × bend × throw momentum, without the @@ -3097,7 +3291,7 @@ fn deck_panel( // settling is the platter feedback. let tempo_rate = (1.0 + deck_ui.pitch_percent as f64 / 100.0) * deck_ui.bend as f64; - // Header: artwork | title/artist | (big white time + big amber BPM). + // Header: artwork | title/artist/key | elapsed + remaining readouts. ui.horizontal(|ui| { let art_size = egui::vec2(ARTWORK_SIZE, ARTWORK_SIZE); match &deck_ui.artwork { @@ -3122,9 +3316,20 @@ fn deck_panel( } } ui.add_space(4.0); + // Time-column width, sized to the widest value ("-" prefix included) + // so the digits don't jitter the layout. + let time_value_w = ui.fonts(|f| { + f.layout_no_wrap( + "-88:88.8".to_owned(), + egui::FontId::monospace(20.0), + egui::Color32::WHITE, + ) + .size() + .x + }); // Title/artist, width-constrained and truncated so a long name can't - // grow into (and overlap) the right-aligned time + BPM readouts. - let title_w = (ui.available_width() - 200.0).max(60.0); + // grow into (and overlap) the right-aligned time readout + toggle. + let title_w = (ui.available_width() - time_value_w - 60.0).max(60.0); ui.allocate_ui_with_layout( egui::vec2(title_w, ARTWORK_SIZE), egui::Layout::top_down(egui::Align::Min), @@ -3139,114 +3344,60 @@ fn deck_panel( ui.add(egui::Label::new(egui::RichText::new(artist).weak()).truncate()); } if let Some(key) = &deck_ui.key { - ui.label( - egui::RichText::new(key) - .color(egui::Color32::from_rgb(120, 190, 255)) - .size(11.0), - ); + ui.add_space(2.0); + key_badge(ui, key); } } else { ui.label(egui::RichText::new("No track loaded").weak().size(13.0)); } }, ); - // Right side: the inner right-to-left row fills the remaining width and - // right-aligns, so the amber BPM sits at the far right with the white - // time just to its left. The box + "BPM"/key labels are painted around - // the value afterwards (a Frame would inherit the width-filling layout - // and stretch). - let mut bpm_rect = None; + // Right side: one time readout — a small caption naming the mode over + // a big white monospace value — with the ⏱ button toggling between + // elapsed and remaining. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.vertical(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(8.0); // right margin for the painted box - // Reserve a fixed-width slot sized to the widest value - // ("888.8") so the box — and the controls to its left — - // never shift with the number of BPM digits. The value is - // painted right-aligned into this slot below. - let value_w = ui.fonts(|f| { - f.layout_no_wrap( - "888.8".to_owned(), - egui::FontId::monospace(20.0), - egui::Color32::WHITE, - ) - .size() - .x - }); - let (slot, _) = - ui.allocate_exact_size(egui::vec2(value_w, 24.0), egui::Sense::hover()); - bpm_rect = Some(slot); - ui.add_space(16.0); - // Toggle button at a fixed spot just left of the BPM; the - // time to its left grows with elapsed/remaining width. - if ui - .small_button("⏱") - .on_hover_text("Toggle elapsed / remaining") - .clicked() - { - deck_ui.show_remaining = !deck_ui.show_remaining; - } - ui.add_space(6.0); - let time_text = if deck_ui.show_remaining { - format!( - "-{}", - format_time(total.saturating_sub(playhead), sample_rate) - ) - } else { - format_time(playhead, sample_rate) - }; + ui.add_space(8.0); + if ui + .small_button("⏱") + .on_hover_text("Toggle elapsed / remaining") + .clicked() + { + deck_ui.show_remaining = !deck_ui.show_remaining; + } + ui.add_space(4.0); + let (caption, value) = if deck_ui.show_remaining { + ( + "REMAINING", + format!( + "-{}", + format_time(total.saturating_sub(playhead), sample_rate) + ), + ) + } else { + ("ELAPSED", format_time(playhead, sample_rate)) + }; + ui.allocate_ui_with_layout( + egui::vec2(time_value_w, ARTWORK_SIZE), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add_space(10.0); + section_caption(ui, caption); ui.label( - egui::RichText::new(time_text) + egui::RichText::new(value) .color(egui::Color32::WHITE) .strong() .monospace() .size(20.0), ); - }); - }); - }); - // Box around the BPM value, with the "BPM" label at its top-left corner - // (and the key, if any, at the top-right). - if let Some(r) = bpm_rect { - let box_rect = egui::Rect::from_min_max( - r.min - egui::vec2(6.0, 15.0), - r.max + egui::vec2(6.0, 4.0), - ); - let border = ui.visuals().widgets.noninteractive.bg_stroke.color; - let label = ui.visuals().weak_text_color(); - let painter = ui.painter(); - painter.rect_stroke( - box_rect, - 4.0, - egui::Stroke::new(1.0, border), - egui::StrokeKind::Outside, - ); - // Value, right-aligned within its fixed slot. - let bpm_text = if deck_ui.bpm > 0.0 && has_track { - format!("{:.1}", deck_ui.bpm * tempo_rate) - } else { - "0.0".to_string() - }; - painter.text( - egui::pos2(r.max.x, r.center().y), - egui::Align2::RIGHT_CENTER, - bpm_text, - egui::FontId::monospace(20.0), - ACCENT, - ); - painter.text( - box_rect.min + egui::vec2(6.0, 2.0), - egui::Align2::LEFT_TOP, - "BPM", - egui::FontId::proportional(10.0), - label, + }, ); - } + }); }); ui.add_space(6.0); - // Waveform on the left; tempo fader + KEY / Master-Sync box carve out a - // fixed column on the right (like a hardware deck's pitch strip). + // Waveform on the left; the BPM / keylock / master-sync / pitch sidebar + // carves out a fixed column on the right (like a hardware deck's pitch + // strip). let loop_region = shared.loop_region(); ui.horizontal(|ui| { const RIGHT_W: f32 = 60.0; @@ -3259,7 +3410,6 @@ fn deck_panel( // (both directions); the drop releases the momentum into a // glide that eases back to play speed (or rest), handing off // to a warm-started engine at the predicted landing. - let display_pos = playhead as f64; let gesture = paint_zoomed( ui, ZoomedParams { @@ -3270,10 +3420,21 @@ fn deck_panel( sample_rate, loop_region, loop_in: deck_ui.loop_in_staged, + hot_cues: &deck_ui.hot_cues, + cue_point: has_track.then(|| shared.cue_point.load(Ordering::Relaxed) as usize), + ghost: deck_ui.ghost.as_ref().map(GhostAnim::params), }, &mut deck_ui.zoom, ); - handle_scrub_gesture(deck_ui, gesture, has_track, has_audio, playing, playhead); + handle_scrub_gesture( + deck_ui, + gesture, + has_track, + has_audio, + playing, + display_pos, + sample_rate, + ); // Lighting / Pixels / FX trigger lanes, scrolling in lockstep // with the zoomed view above. @@ -3307,6 +3468,7 @@ fn deck_panel( loop_region, loop_in: deck_ui.loop_in_staged, hot_cues: &deck_ui.hot_cues, + marks: &deck_ui.marks, }, ) && has_track { @@ -3335,144 +3497,148 @@ fn deck_panel( let wave_h = wave.response.rect.height(); ui.vertical(|ui| { ui.set_width(RIGHT_W); - ui.add_enabled_ui(has_track, |ui| { - deck_tempo_column(ui, deck_ui, is_master, wave_h, &mut response); - }); + deck_sidebar( + ui, + deck_ui, + idx, + is_master, + has_track, + tempo_rate, + wave_h, + &mut response, + ); }); }); ui.add_space(8.0); ui.add_enabled_ui(has_track, |ui| { - // Row 1: transport (play / cue / pitch bend) then the loop controls, - // all at one consistent height. + // Row 1: captioned groups — transport (play / cue), nudge, loops — + // all buttons at one consistent height. + const GROUP_H: f32 = 50.0; ui.horizontal(|ui| { - // PLAY/PAUSE — green accents. - let play_label = if playing { "⏸" } else { "▶" }; - if ui - .add_sized( - [50.0, 36.0], - egui::Button::new( - egui::RichText::new(play_label) - .size(18.0) - .color(egui::Color32::from_rgb(90, 220, 120)), + control_group(ui, "TRANSPORT", |ui| { + // PLAY/PAUSE — green accents. + let play_label = if playing { "⏸" } else { "▶" }; + if ui + .add_sized( + [50.0, 36.0], + egui::Button::new( + egui::RichText::new(play_label) + .size(18.0) + .color(egui::Color32::from_rgb(90, 220, 120)), + ) + .fill(egui::Color32::from_rgb(32, 56, 40)), ) - .fill(egui::Color32::from_rgb(32, 56, 40)), - ) - .clicked() - { - deck_ui.toggle_play(); - } - - // CUE — CDJ semantics on press/release edges; yellow accents. - let cue_resp = ui.add_sized( - [50.0, 36.0], - egui::Button::new( - egui::RichText::new("CUE") - .size(15.0) - .color(egui::Color32::from_rgb(255, 215, 70)), - ) - .fill(egui::Color32::from_rgb(58, 50, 26)), - ); - let cue_down = cue_resp.is_pointer_button_down_on(); - let pressed = cue_down && !deck_ui.cue_was_down; - let released = !cue_down && deck_ui.cue_was_down; - deck_ui.cue_was_down = cue_down; - - if pressed { - deck_ui.cue_press(); - } - if released { - deck_ui.cue_release(); - } - - ui.separator(); - - // Pitch bend: momentary ±4% while held. - let bend_minus = ui - .add_sized([28.0, 36.0], egui::Button::new("−")) - .is_pointer_button_down_on(); - let bend_plus = ui - .add_sized([28.0, 36.0], egui::Button::new("+")) - .is_pointer_button_down_on(); - deck_ui.bend = if bend_minus { - 0.96 - } else if bend_plus { - 1.04 - } else { - 1.0 - }; - if bend_minus || bend_plus { - ui.ctx().request_repaint(); - } + .clicked() + { + response.play_toggled = true; + } - ui.separator(); + // CUE — CDJ semantics on press/release edges; amber outline. + let cue_resp = ui.add_sized( + [50.0, 36.0], + egui::Button::new(egui::RichText::new("CUE").size(15.0).color(ACCENT)) + .stroke(egui::Stroke::new(1.0, ACCENT)) + .fill(ACCENT_FILL), + ); + let cue_down = cue_resp.is_pointer_button_down_on(); + let pressed = cue_down && !deck_ui.cue_was_down; + let released = !cue_down && deck_ui.cue_was_down; + deck_ui.cue_was_down = cue_down; - // Loops: manual in/out, 4-beat quantized autoloop, halve/double - // between 1/4 and 16 beats (gapless feed-thread re-anchor). Fixed - // height, text-tight width so the row stays compact. - let has_loop = loop_region.is_some(); - let grid_ok = deck_ui.marks.is_usable(); - let btn = - |txt: &str| egui::Button::new(txt.to_string()).min_size(egui::vec2(0.0, 36.0)); + if pressed { + deck_ui.cue_press(); + } + if released { + deck_ui.cue_release(); + } + }); - if ui.add(btn("IN")).clicked() && has_track { - deck_ui.loop_in_staged = - Some(quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead)); - } - if ui.add(btn("OUT")).clicked() - && has_track - && let Some(start) = deck_ui.loop_in_staged - { - let end = quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead); - if end > start { - shared.set_loop(Some((start, end))); - let median = deck_ui.marks.median_beat_frames(); - deck_ui.loop_beats = if median > 0.0 { - ((end - start) as f64 / median).clamp(0.25, 64.0) - } else { - 4.0 - }; - deck_ui.loop_in_staged = None; + group_divider(ui, GROUP_H); + + control_group(ui, "NUDGE", |ui| { + // Pitch bend: momentary ±4% while held. + let bend_minus = ui + .add_sized([28.0, 36.0], egui::Button::new("−")) + .is_pointer_button_down_on(); + let bend_plus = ui + .add_sized([28.0, 36.0], egui::Button::new("+")) + .is_pointer_button_down_on(); + deck_ui.bend = if bend_minus { + 0.96 + } else if bend_plus { + 1.04 + } else { + 1.0 + }; + if bend_minus || bend_plus { + ui.ctx().request_repaint(); } - } + }); - ui.separator(); - if ui - .add_enabled(grid_ok && has_track, btn("4 BEAT")) - .clicked() - { - deck_ui.autoloop_4(); - } + group_divider(ui, GROUP_H); + + control_group(ui, "LOOP", |ui| { + // Loops: manual in/out, quantized autoloop at the shown + // length, halve/double between 1/16 and 16 beats (gapless + // feed-thread re-anchor). Fixed height, text-tight width so + // the row stays compact. + let has_loop = loop_region.is_some(); + let grid_ok = deck_ui.marks.is_usable(); + let btn = + |txt: &str| egui::Button::new(txt.to_string()).min_size(egui::vec2(0.0, 36.0)); + + if ui.add(btn("IN")).clicked() && has_track { + deck_ui.loop_in_staged = + Some(quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead)); + } + if ui.add(btn("OUT")).clicked() + && has_track + && let Some(start) = deck_ui.loop_in_staged + { + let end = quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead); + if end > start { + shared.set_loop(Some((start, end))); + let median = deck_ui.marks.median_beat_frames(); + deck_ui.loop_beats = if median > 0.0 { + ((end - start) as f64 / median).clamp(0.25, 64.0) + } else { + 4.0 + }; + deck_ui.loop_in_staged = None; + } + } - ui.separator(); - if ui.add_enabled(has_loop, btn("÷2")).clicked() - && let Some((start, _)) = loop_region - { - deck_ui.loop_beats = (deck_ui.loop_beats / 2.0).max(0.0625); - let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); - shared.set_loop(Some((start, end.max(start + 1)))); - } - ui.label( - egui::RichText::new(if has_loop { - format_beats(deck_ui.loop_beats) - } else { - "—".to_string() - }) - .monospace() - .size(12.0), - ); - if ui.add_enabled(has_loop, btn("×2")).clicked() - && let Some((start, _)) = loop_region - { - deck_ui.loop_beats = (deck_ui.loop_beats * 2.0).min(16.0); - let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); - shared.set_loop(Some((start, end.max(start + 1)))); - } + // Length chip: autoloop at the shown length; lit while a + // loop is active. + let chip = ui + .add_enabled_ui(grid_ok && has_track, |ui| { + let label = format!("{} BEATS", format_beats(deck_ui.loop_beats)); + outlined_toggle(ui, has_loop, &label, [72.0, 36.0]) + }) + .inner; + if chip.on_hover_text("Autoloop at this length").clicked() { + deck_ui.autoloop(deck_ui.loop_beats); + } - ui.separator(); - if ui.add_enabled(has_loop, btn("EXIT")).clicked() { - shared.set_loop(None); - } + if ui.add_enabled(has_loop, btn("÷2")).clicked() + && let Some((start, _)) = loop_region + { + deck_ui.loop_beats = (deck_ui.loop_beats / 2.0).max(0.0625); + let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); + shared.set_loop(Some((start, end.max(start + 1)))); + } + if ui.add_enabled(has_loop, btn("×2")).clicked() + && let Some((start, _)) = loop_region + { + deck_ui.loop_beats = (deck_ui.loop_beats * 2.0).min(16.0); + let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); + shared.set_loop(Some((start, end.max(start + 1)))); + } + if ui.add_enabled(has_loop, btn("EXIT")).clicked() { + shared.set_loop(None); + } + }); }); // Row 2: hot-cue pads (8) + GATE / Q. Normal mode: empty = set at the @@ -3481,50 +3647,64 @@ fn deck_panel( ui.add_space(6.0); ui.horizontal(|ui| { // The pads shrink when the deck column is narrow so the row - // (8 pads + separator + GATE/Q, ~88 pt of tail) never widens - // the panel — at the minimum window size that overflow would - // push deck B past the right edge. + // (8 pads + divider + GATE/QUANTIZE/AUTO CUE, ~175 pt of tail) + // never widens the panel — at the minimum window size that + // overflow would push deck B past the right edge and wrap the + // AUTO CUE caption. let gap = ui.spacing().item_spacing.x; - let pad_w = ((ui.available_width() - 88.0 - 7.0 * gap) / 8.0).clamp(24.0, 40.0); - for i in 0..8 { - let set = deck_ui.hot_cues[i].is_some(); - let mut button = - egui::Button::new(egui::RichText::new(format!("{}", i + 1)).size(13.0).color( - if set { - egui::Color32::BLACK - } else { - egui::Color32::from_rgb(140, 140, 150) - }, - )); - if set { - button = button.fill(ACCENT); - } - let resp = ui.add_sized([pad_w, 30.0], button); - let down = resp.is_pointer_button_down_on(); - let pressed = down && !deck_ui.hotcue_was_down[i]; - deck_ui.hotcue_was_down[i] = down; - - if resp.secondary_clicked() { - deck_ui.hot_cues[i] = None; - continue; + let pad_w = ((ui.available_width() - 175.0 - 8.0 * gap) / 8.0).clamp(18.0, 40.0); + control_group(ui, "HOT CUES", |ui| { + for i in 0..8 { + let set = deck_ui.hot_cues[i].is_some(); + let mut button = egui::Button::new( + egui::RichText::new(format!("{}", i + 1)) + .size(13.0) + .color(if set { + egui::Color32::BLACK + } else { + egui::Color32::from_rgb(140, 140, 150) + }), + ); + if set { + button = button.fill(ACCENT); + } + let resp = ui.add_sized([pad_w, 30.0], button); + let down = resp.is_pointer_button_down_on(); + let pressed = down && !deck_ui.hotcue_was_down[i]; + deck_ui.hotcue_was_down[i] = down; + + if resp.secondary_clicked() { + deck_ui.hot_cues[i] = None; + continue; + } + if pressed && deck_ui.hot_cue_press(i) && deck_ui.gated { + deck_ui.gated_held = Some(i); + } } - if pressed && deck_ui.hot_cue_press(i) && deck_ui.gated { - deck_ui.gated_held = Some(i); + // Gated release: the held slot's button is no longer down. + if let Some(held) = deck_ui.gated_held + && !deck_ui.hotcue_was_down[held] + { + deck_ui.gated_held = None; + shared.set_transport(Transport::Paused); } - } - // Gated release: the held slot's button is no longer down. - if let Some(held) = deck_ui.gated_held - && !deck_ui.hotcue_was_down[held] - { - deck_ui.gated_held = None; - shared.set_transport(Transport::Paused); - } + }); + + group_divider(ui, 44.0); - ui.separator(); - ui.toggle_value(&mut deck_ui.gated, "GATE") - .on_hover_text("Gated hot cues: play while held, stop on release"); - ui.toggle_value(&mut deck_ui.quantize, "Q") - .on_hover_text("Quantize hot cues and loops to the beat grid"); + control_group(ui, "GATE", |ui| { + state_toggle(ui, &mut deck_ui.gated, [40.0, 30.0]) + .on_hover_text("Gated hot cues: play while held, stop on release"); + }); + control_group(ui, "QUANTIZE", |ui| { + state_toggle(ui, &mut deck_ui.quantize, [40.0, 30.0]) + .on_hover_text("Quantize hot cues and loops to the beat grid"); + }); + control_group(ui, "AUTO CUE", |ui| { + state_toggle(ui, &mut deck_ui.auto_cue, [40.0, 30.0]).on_hover_text( + "On load, set the cue to the first downbeat and park the deck there", + ); + }); }); }); @@ -3549,119 +3729,135 @@ fn deck_panel( response } -/// Right-of-waveform pitch strip: KEY toggle and a Master/Sync box stacked on -/// top, then a vertical tempo fader that fills the remaining waveform height -/// with a `%` readout and the ±range button. `height` is the waveform height, -/// used to size the fader so the column spans it. -fn deck_tempo_column( +/// Right-of-waveform sidebar: big BPM readout, keylock, Master/Sync, +/// pitch readout + vertical tempo fader with a labeled scale, and the +/// range selector. `height` is the waveform stack height, used to size the +/// fader so the column spans it. +#[allow(clippy::too_many_arguments)] +fn deck_sidebar( ui: &mut egui::Ui, deck_ui: &mut DeckUi, + deck_idx: usize, is_master: bool, + has_track: bool, + tempo_rate: f64, height: f32, response: &mut DeckPanelResponse, ) { - let full = ui.available_width(); + // Faint divider between the waveform stack and the sidebar. + let left_x = ui.max_rect().left() - 4.0; + let top_y = ui.cursor().top(); + ui.painter().line_segment( + [ + egui::pos2(left_x, top_y), + egui::pos2(left_x, top_y + height), + ], + ui.visuals().widgets.noninteractive.bg_stroke, + ); - // KEY + Master/Sync box stacked on top; measure their height so the fader - // below can fill the rest of the waveform's height. - let top = ui.scope(|ui| { - if ui - .add_sized( - [full, 22.0], - egui::SelectableLabel::new(deck_ui.keylock, "KEY"), - ) - .on_hover_text("Keylock: keep pitch constant while tempo changes") - .clicked() - { - deck_ui.keylock = !deck_ui.keylock; - } - ui.add_space(4.0); - // Master/Sync box: mutually exclusive, at most one lit (master deck - // shows MASTER with SYNC disabled; a follower shows SYNC; neither lit - // = independent). - // Tightened margin + 10 pt text so MASTER fits the narrow strip - // unwrapped; SYNC matches for consistency. - egui::Frame::group(ui.style()) - .inner_margin(4.0) - .show(ui, |ui| { - let w = ui.available_width(); - if ui - .add_sized( - [w, 20.0], - egui::SelectableLabel::new( - is_master, - egui::RichText::new("MASTER").size(10.0), - ), - ) - .on_hover_text("Make this deck the tempo reference") - .clicked() - && !is_master - { - response.master_clicked = true; - } - let sync = ui - .add_enabled_ui(!is_master, |ui| { - ui.add_sized( - [w, 20.0], - egui::SelectableLabel::new( - deck_ui.synced, - egui::RichText::new("SYNC").size(10.0), - ), - ) - .on_hover_text("Follow the master deck's tempo and beat phase") - }) - .inner; - if sync.clicked() { - deck_ui.synced = !deck_ui.synced; - if deck_ui.synced { - response.sync_engaged = true; - } - } - }); - }); - let used = top.response.rect.height(); - let fader_h = (height - used - 48.0).max(80.0); + let full = ui.available_width(); - ui.add_space(6.0); + // BPM stays readable even with no track loaded, so it sits outside the + // disabled scope below. ui.vertical_centered(|ui| { + section_caption(ui, "BPM"); + let bpm_text = if deck_ui.bpm > 0.0 && has_track { + format!("{:.1}", deck_ui.bpm * tempo_rate) + } else { + "0.0".to_string() + }; ui.label( - egui::RichText::new(format!("{:+.1}%", deck_ui.pitch_percent)) + egui::RichText::new(bpm_text) .monospace() - .size(11.0), + .size(16.0) + .color(ACCENT), ); + }); + + ui.add_enabled_ui(has_track, |ui| { + ui.add_space(4.0); + ui.vertical_centered(|ui| section_caption(ui, "KEYLOCK")); + state_toggle(ui, &mut deck_ui.keylock, [full, 20.0]) + .on_hover_text("Keylock: keep pitch constant while tempo changes"); + ui.add_space(4.0); + // Master/Sync: mutually exclusive, at most one lit (master deck + // shows MASTER with SYNC disabled; a follower shows SYNC; neither + // lit = independent). Stacked — the narrow strip can't fit both + // side by side. + if outlined_toggle(ui, is_master, "MASTER", [full, 20.0]) + .on_hover_text("Make this deck the tempo reference") + .clicked() + && !is_master + { + response.master_clicked = true; + } + let sync = ui + .add_enabled_ui(!is_master, |ui| { + outlined_toggle(ui, deck_ui.synced, "SYNC", [full, 20.0]) + .on_hover_text("Follow the master deck's tempo and beat phase") + }) + .inner; + if sync.clicked() { + deck_ui.synced = !deck_ui.synced; + if deck_ui.synced { + response.sync_engaged = true; + } + } + + ui.add_space(6.0); + ui.vertical_centered(|ui| { + section_caption(ui, "PITCH"); + ui.label( + egui::RichText::new(format!("{:+.1}%", deck_ui.pitch_percent)) + .monospace() + .size(11.0), + ); + }); + // Size the fader so the sidebar spans the waveform stack: subtract + // what the column has consumed so far (measured from its absolute + // top) and the RANGE caption + combo below. + let used = ui.cursor().top() - top_y; + let fader_h = (height - used - 56.0).max(80.0); let range = deck_ui.pitch_range; let mut pct = deck_ui.pitch_percent; - // Absolute-position fader: `.changed()` only fires on a real grab, so - // touching it hands control back and drops sync (matching the old - // slider). While synced, update_tempo keeps writing pitch_percent and - // the fader just displays it. - if ui - .add( + ui.vertical_centered(|ui| { + // Absolute-position fader: `.changed()` only fires on a real + // grab, so touching it hands control back and drops sync + // (matching the old slider). While synced, update_tempo keeps + // writing pitch_percent and the fader just displays it. + // Pitch-strip look: dark slot, chunky cap, dense tick ladder. + let fader = ui.add( Fader::new(&mut pct, -range..=range, ACCENT) .vertical(true) - .size([24.0, fader_h]) - .notches(Notches::Even(10)) + .size([32.0, fader_h]) + .groove_width(8.0) + .cap_size(30.0, 12.0) + .notches(Notches::Even(16)) .default_value(0.0), - ) - .changed() - { - deck_ui.pitch_percent = pct; - deck_ui.synced = false; - } - if ui - .add_sized([full, 20.0], egui::Button::new(format!("±{:.0}", range))) - .on_hover_text("Tempo range") - .clicked() - { - deck_ui.pitch_range = match deck_ui.pitch_range as u32 { - 8 => 16.0, - 16 => 50.0, - _ => 8.0, - }; - deck_ui.pitch_percent = deck_ui - .pitch_percent - .clamp(-deck_ui.pitch_range, deck_ui.pitch_range); - } + ); + if fader.changed() { + deck_ui.pitch_percent = pct; + deck_ui.synced = false; + } + }); + ui.vertical_centered(|ui| { + ui.add_space(6.0); + section_caption(ui, "RANGE"); + let prev = deck_ui.pitch_range; + egui::ComboBox::from_id_salt(("pitch-range", deck_idx)) + .selected_text(format!("±{prev:.0}%")) + .width(full) + .show_ui(ui, |ui| { + for r in [8.0_f32, 16.0, 50.0] { + ui.selectable_value(&mut deck_ui.pitch_range, r, format!("±{r:.0}%")); + } + }); + if deck_ui.pitch_range != prev { + deck_ui.pitch_percent = deck_ui + .pitch_percent + .clamp(-deck_ui.pitch_range, deck_ui.pitch_range); + } + }); }); } @@ -3906,6 +4102,86 @@ fn process_cpu_secs() -> f64 { } } +/// Small uppercase weak caption above a control group ("TRANSPORT", …). +fn section_caption(ui: &mut egui::Ui, text: &str) { + ui.label(egui::RichText::new(text).size(9.0).weak()); +} + +/// Caption above a horizontal control row; returns the row's inner value. +fn control_group( + ui: &mut egui::Ui, + caption: &str, + content: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.vertical(|ui| { + section_caption(ui, caption); + ui.add_space(2.0); + ui.horizontal(content).inner + }) + .inner +} + +/// Faint vertical divider between captioned control groups. +fn group_divider(ui: &mut egui::Ui, height: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(7.0, height), egui::Sense::hover()); + let x = rect.center().x; + ui.painter().line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + ui.visuals().widgets.noninteractive.bg_stroke, + ); +} + +/// Fill behind amber-outlined controls: a dark amber wash. +const ACCENT_FILL: egui::Color32 = egui::Color32::from_rgb(46, 36, 16); + +/// ON/OFF toggle whose label is the state; amber-outlined when on. +fn state_toggle(ui: &mut egui::Ui, on: &mut bool, size: [f32; 2]) -> egui::Response { + let resp = outlined_toggle(ui, *on, if *on { "ON" } else { "OFF" }, size); + if resp.clicked() { + *on = !*on; + } + resp +} + +/// Latching button with an amber outline when active (MASTER / SYNC / the +/// loop-length chip). +fn outlined_toggle(ui: &mut egui::Ui, active: bool, label: &str, size: [f32; 2]) -> egui::Response { + let (color, stroke, fill) = if active { + (ACCENT, egui::Stroke::new(1.0, ACCENT), ACCENT_FILL) + } else { + ( + ui.visuals().weak_text_color(), + ui.visuals().widgets.inactive.bg_stroke, + ui.visuals().widgets.inactive.weak_bg_fill, + ) + }; + ui.add_sized( + size, + egui::Button::new(egui::RichText::new(label).size(10.0).color(color)) + .stroke(stroke) + .fill(fill), + ) +} + +/// Musical-key badge in the deck header: a bordered blue chip around the +/// raw tag string. +fn key_badge(ui: &mut egui::Ui, key: &str) { + const KEY_BLUE: egui::Color32 = egui::Color32::from_rgb(120, 190, 255); + let galley = + ui.painter() + .layout_no_wrap(key.to_owned(), egui::FontId::proportional(10.0), KEY_BLUE); + let (rect, _) = + ui.allocate_exact_size(galley.size() + egui::vec2(10.0, 4.0), egui::Sense::hover()); + ui.painter().rect_stroke( + rect, + 3.0, + egui::Stroke::new(1.0, KEY_BLUE), + egui::StrokeKind::Inside, + ); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, KEY_BLUE); +} + fn apply_theme(ctx: &egui::Context) { // Pin to dark regardless of the OS appearance setting; set_visuals only // styles the active theme, so following the system would fall back to diff --git a/crates/halo/src/audio.rs b/crates/halo/src/audio.rs index d178acb..1223970 100644 --- a/crates/halo/src/audio.rs +++ b/crates/halo/src/audio.rs @@ -201,6 +201,7 @@ impl AudioOutput { let phase = deck.shared.scrub.phase(); if prev_phase[i] == ScrubPhase::Idle && phase != ScrubPhase::Idle { voices[i].seed(deck.shared.scrub.target()); + voices[i].set_lead_in(deck.shared.scrub.lead_in()); scr_strips[i].reset(); } if prev_phase[i] != ScrubPhase::Settling && phase == ScrubPhase::Settling { diff --git a/crates/halo/src/fader.rs b/crates/halo/src/fader.rs index c2769ad..92645e7 100644 --- a/crates/halo/src/fader.rs +++ b/crates/halo/src/fader.rs @@ -44,6 +44,10 @@ pub struct Fader<'a> { /// Some(center): draw an accent fill along the groove from `center` to the /// cap as it moves off center (like the bipolar EQ knobs). center_fill: Option, + /// Groove thickness across the travel axis. + groove: f32, + /// Cap size as (span across the travel axis, thickness along it). + cap: Vec2, } impl<'a> Fader<'a> { @@ -58,6 +62,8 @@ impl<'a> Fader<'a> { default, accent, center_fill: None, + groove: GROOVE, + cap: Vec2::new(CAP_HALF_SPAN * 2.0, CAP_THICKNESS), } } @@ -86,6 +92,19 @@ impl<'a> Fader<'a> { self } + /// Groove thickness across the travel axis (default 4.0). + pub fn groove_width(mut self, width: f32) -> Self { + self.groove = width; + self + } + + /// Cap size: span across the travel axis × thickness along it + /// (default 20×9, the mixer look). + pub fn cap_size(mut self, span: f32, thickness: f32) -> Self { + self.cap = Vec2::new(span, thickness); + self + } + fn span(&self) -> f32 { *self.range.end() - *self.range.start() } @@ -102,7 +121,7 @@ impl Widget for Fader<'_> { // The cap center travels between these two points; inset from the // ends by half the cap so it never spills past the track. - let inset = CAP_THICKNESS / 2.0; + let inset = self.cap.y / 2.0; let (lo, hi) = if self.vertical { (rect.bottom() - inset, rect.top() + inset) // norm 0 = bottom } else { @@ -136,28 +155,29 @@ impl Widget for Fader<'_> { // Groove along the travel axis. let groove = if self.vertical { - Rect::from_center_size(center, Vec2::new(GROOVE, (hi - lo).abs() + CAP_THICKNESS)) + Rect::from_center_size(center, Vec2::new(self.groove, (hi - lo).abs() + self.cap.y)) } else { - Rect::from_center_size(center, Vec2::new((hi - lo).abs() + CAP_THICKNESS, GROOVE)) + Rect::from_center_size(center, Vec2::new((hi - lo).abs() + self.cap.y, self.groove)) }; painter.rect_filled(groove, 2.0, visuals.extreme_bg_color); // Notches: short ticks perpendicular to the groove. + let half_groove = self.groove / 2.0; let tick = |painter: &egui::Painter, t: f32| { let p = lo + (hi - lo) * t; let (a, b, c, d) = if self.vertical { ( - egui::pos2(center.x - GROOVE / 2.0 - NOTCH_GAP - NOTCH_LEN, p), - egui::pos2(center.x - GROOVE / 2.0 - NOTCH_GAP, p), - egui::pos2(center.x + GROOVE / 2.0 + NOTCH_GAP, p), - egui::pos2(center.x + GROOVE / 2.0 + NOTCH_GAP + NOTCH_LEN, p), + egui::pos2(center.x - half_groove - NOTCH_GAP - NOTCH_LEN, p), + egui::pos2(center.x - half_groove - NOTCH_GAP, p), + egui::pos2(center.x + half_groove + NOTCH_GAP, p), + egui::pos2(center.x + half_groove + NOTCH_GAP + NOTCH_LEN, p), ) } else { ( - egui::pos2(p, center.y - GROOVE / 2.0 - NOTCH_GAP - NOTCH_LEN), - egui::pos2(p, center.y - GROOVE / 2.0 - NOTCH_GAP), - egui::pos2(p, center.y + GROOVE / 2.0 + NOTCH_GAP), - egui::pos2(p, center.y + GROOVE / 2.0 + NOTCH_GAP + NOTCH_LEN), + egui::pos2(p, center.y - half_groove - NOTCH_GAP - NOTCH_LEN), + egui::pos2(p, center.y - half_groove - NOTCH_GAP), + egui::pos2(p, center.y + half_groove + NOTCH_GAP), + egui::pos2(p, center.y + half_groove + NOTCH_GAP + NOTCH_LEN), ) }; let stroke = egui::Stroke::new(1.0, track_col); @@ -185,13 +205,13 @@ impl Widget for Fader<'_> { if (p - pc).abs() > 0.5 { let fill = if self.vertical { Rect::from_two_pos( - egui::pos2(center.x - GROOVE / 2.0, pc), - egui::pos2(center.x + GROOVE / 2.0, p), + egui::pos2(center.x - half_groove, pc), + egui::pos2(center.x + half_groove, p), ) } else { Rect::from_two_pos( - egui::pos2(pc, center.y - GROOVE / 2.0), - egui::pos2(p, center.y + GROOVE / 2.0), + egui::pos2(pc, center.y - half_groove), + egui::pos2(p, center.y + half_groove), ) }; let fill_col = if active { @@ -205,15 +225,9 @@ impl Widget for Fader<'_> { // Cap at the current value (always accent). let cap = if self.vertical { - Rect::from_center_size( - egui::pos2(center.x, p), - Vec2::new(CAP_HALF_SPAN * 2.0, CAP_THICKNESS), - ) + Rect::from_center_size(egui::pos2(center.x, p), self.cap) } else { - Rect::from_center_size( - egui::pos2(p, center.y), - Vec2::new(CAP_THICKNESS, CAP_HALF_SPAN * 2.0), - ) + Rect::from_center_size(egui::pos2(p, center.y), Vec2::new(self.cap.y, self.cap.x)) }; let cap_col = if active { self.accent diff --git a/crates/halo/src/state.rs b/crates/halo/src/state.rs index 8188ef4..3a0a8ee 100644 --- a/crates/halo/src/state.rs +++ b/crates/halo/src/state.rs @@ -196,6 +196,10 @@ pub struct ScrubState { phase: AtomicU8, /// Pointer-target source frame (valid while `Active`). target_frame: AtomicF64, + /// Elastic lead-in depth for the current gesture (source frames): + /// positions in `[-lead_in, 0)` are draggable silence before the track + /// start. Published by the UI at engage, copied into the voice's floor. + lead_in_frames: AtomicF64, /// Rate the release glide eases toward: the deck's tempo rate resumes /// playback speed, 0.0 spins down to rest. settle_rate_target: AtomicF64, @@ -216,6 +220,7 @@ impl ScrubState { Self { phase: AtomicU8::new(ScrubPhase::Idle as u8), target_frame: AtomicF64::new(0.0), + lead_in_frames: AtomicF64::new(0.0), settle_rate_target: AtomicF64::new(0.0), voice_frame: AtomicF64::new(0.0), landing: AtomicF64::new(0.0), @@ -232,16 +237,22 @@ impl ScrubState { } /// Engage the scrub at `frame` (the playhead where the drag started, or - /// the gliding voice position on a mid-settle re-grab). The target is - /// published before the phase so the audio callback never sees a stale - /// target on engage. - pub fn begin(&self, frame: f64) { + /// the gliding voice position on a mid-settle re-grab). The target and + /// lead-in are published before the phase so the audio callback never + /// sees stale values on engage. + pub fn begin(&self, frame: f64, lead_in_frames: f64) { + self.lead_in_frames.store(lead_in_frames); self.target_frame.store(frame); self.voice_frame.store(frame); self.phase .store(ScrubPhase::Active as u8, Ordering::Release); } + /// Elastic lead-in depth for the current gesture (source frames). + pub fn lead_in(&self) -> f64 { + self.lead_in_frames.load() + } + pub fn update_target(&self, frame: f64) { self.target_frame.store(frame); } diff --git a/crates/halo/src/waveform/lanes.rs b/crates/halo/src/waveform/lanes.rs index a50b1df..7f2c7d4 100644 --- a/crates/halo/src/waveform/lanes.rs +++ b/crates/halo/src/waveform/lanes.rs @@ -19,6 +19,8 @@ const BAR_INSET_Y: f32 = 2.5; const MIN_BAR_W: f32 = 2.0; /// Extra dim applied to every lane when the deck isn't driving lighting. const INACTIVE_DIM: f32 = 0.30; +/// Width of the left label gutter in points. +const LABEL_GUTTER_W: f32 = 30.0; pub struct LanesParams<'a> { pub cues: &'a CueSet, @@ -108,20 +110,44 @@ pub fn paint_lanes(ui: &mut egui::Ui, params: LanesParams<'_>, span: &ZoomSpan) } } - // Labels over the bars (no reserved gutter, so the mapping stays - // full-width and pixel-identical to the zoomed view above), with a - // backing wash for legibility. + // Left label gutter, painted over the bars so the frame→x mapping stays + // full-width and pixel-identical to the zoomed view above. + let gutter = egui::Rect::from_min_max( + rect.left_top(), + egui::pos2(rect.left() + LABEL_GUTTER_W, rect.bottom()), + ); + painter.rect_filled( + gutter, + egui::CornerRadius { + nw: 4, + ne: 0, + sw: 4, + se: 0, + }, + palette::LANE_BG, + ); + for row in 1..LANES.len() { + let y = row_rect(row).top() - 0.5; + painter.line_segment( + [egui::pos2(gutter.left(), y), egui::pos2(gutter.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + painter.line_segment( + [ + egui::pos2(gutter.right() + 0.5, rect.top()), + egui::pos2(gutter.right() + 0.5, rect.bottom()), + ], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); for (row, &(_, label, color)) in LANES.iter().enumerate() { - let rr = row_rect(row); - let galley = painter.layout_no_wrap( - label.to_owned(), + painter.text( + egui::pos2(gutter.center().x, row_rect(row).center().y), + egui::Align2::CENTER_CENTER, + label, egui::FontId::monospace(8.0), - dim(color, 0.5), + dim(color, 0.9), ); - let pos = egui::pos2(rr.left() + 4.0, rr.center().y - galley.size().y / 2.0); - let backing = egui::Rect::from_min_size(pos, galley.size()).expand2(egui::vec2(2.0, 0.0)); - painter.rect_filled(backing, 2.0, palette::LANE_BG.gamma_multiply(0.8)); - painter.galley(pos, galley, color); } // Continue the zoomed view's centered playhead through the strip. diff --git a/crates/halo/src/waveform/mod.rs b/crates/halo/src/waveform/mod.rs index 536c859..cad17e2 100644 --- a/crates/halo/src/waveform/mod.rs +++ b/crates/halo/src/waveform/mod.rs @@ -15,7 +15,7 @@ pub use lanes::{LanesParams, paint_lanes}; pub use lanes_editor::{EditorInteraction, LanesEditorParams, lanes_editor, snap_frame}; pub use overview::{OverviewParams, OverviewTexture, paint_overview}; pub use peaks::BandPeaks; -pub use zoomed::{ScrubGesture, ZoomSpan, ZoomedParams, paint_zoomed}; +pub use zoomed::{GhostPlayhead, ScrubGesture, ZoomSpan, ZoomedParams, paint_zoomed}; /// Label + color per lane, shared by the perform strip, the Prepare /// editor, and the programmer UI. @@ -46,6 +46,8 @@ pub(crate) mod palette { pub const BAND_HIGH: Color32 = Color32::from_rgb(235, 235, 240); /// Zoomed-view playhead. pub const PLAYHEAD: Color32 = Color32::from_rgb(230, 40, 40); + /// Cue point / hot cue markers on the zoomed view. + pub const CUE_MARKER: Color32 = PLAYHEAD; /// Overview position cursor. pub const CURSOR: Color32 = Color32::WHITE; /// Regular beat tick. @@ -245,6 +247,13 @@ impl GridMarks { self.frames.partition_point(|&f| f <= frame).checked_sub(1) } + /// Frame of the first flagged downbeat; falls back to the first beat + /// when no downbeat was detected. None on an empty grid. + pub fn first_downbeat_frame(&self) -> Option { + let i = self.downbeat.iter().position(|&d| d).unwrap_or(0); + self.frames.get(i).copied() + } + /// Frame of the bar start (downbeat) at or before `frame`. pub fn bar_start(&self, frame: f64) -> Option { let mut i = self.beat_at_or_before(frame)?; @@ -401,6 +410,26 @@ mod tests { assert_eq!(marks.bar_beat(700.0), Some((0, 4))); } + #[test] + fn first_downbeat_frame_finds_flagged_downbeat() { + // test_grid's first downbeat is beat idx 2 -> frame 200. + assert_eq!(test_grid().first_downbeat_frame(), Some(200.0)); + } + + #[test] + fn first_downbeat_frame_falls_back_to_first_beat() { + let mut grid = timestretch::BeatGrid::empty(100); + grid.beats = (0..8).map(|i| i as f64 * 100.0).collect(); + let marks = GridMarks::from_grid(&grid); + assert_eq!(marks.first_downbeat_frame(), Some(0.0)); + } + + #[test] + fn first_downbeat_frame_none_on_empty_grid() { + let marks = GridMarks::from_grid(×tretch::BeatGrid::empty(100)); + assert_eq!(marks.first_downbeat_frame(), None); + } + #[test] fn visible_range_is_half_open() { let marks = test_grid(); diff --git a/crates/halo/src/waveform/overview.rs b/crates/halo/src/waveform/overview.rs index 63339de..e35a485 100644 --- a/crates/halo/src/waveform/overview.rs +++ b/crates/halo/src/waveform/overview.rs @@ -6,12 +6,12 @@ use eframe::egui; use super::peaks::{BandPeaks, PeakLevel}; -use super::{paint_placeholder, palette}; +use super::{GridMarks, paint_placeholder, palette}; /// Strip height in points. -const STRIP_HEIGHT: f32 = 48.0; +const STRIP_HEIGHT: f32 = 56.0; /// Texture height in pixels (2x the strip for retina crispness). -const TEX_HEIGHT: usize = 96; +const TEX_HEIGHT: usize = 112; /// Perceptual lift applied to column heights (amp^gamma): keeps quiet /// intros/breakdowns visible in the silhouette. 1.0 = linear. const OVERVIEW_GAMMA: f32 = 0.85; @@ -85,6 +85,8 @@ pub struct OverviewParams<'a> { /// Hot cue slots (source frames); markers draw above the wave for each /// defined slot. Pass `&[]` for players without hot cues. pub hot_cues: &'a [Option], + /// Beat grid, for the bar numbers along the top edge. + pub marks: &'a GridMarks, } /// Paint the overview strip. Returns the click-to-seek target as a track @@ -132,6 +134,33 @@ pub fn paint_overview(ui: &mut egui::Ui, params: OverviewParams<'_>) -> Option 0 { + let mut stride = 1u32; + while rect.width() * stride as f32 / (bars as f32) < 40.0 && stride < (1 << 16) { + stride *= 2; + } + for i in (0..params.marks.len()).filter(|&i| params.marks.is_downbeat(i)) { + let bar = params.marks.bar_number(i); + if bar == 0 || !(bar - 1).is_multiple_of(stride) { + continue; + } + painter.text( + egui::pos2(frac_x_f(params.marks.frame(i)) + 2.0, rect.top() + 1.0), + egui::Align2::LEFT_TOP, + bar, + egui::FontId::monospace(8.0), + palette::TEXT_DIM, + ); + } + } + } // Loop region / staged loop-in. if let Some((start, end)) = params.loop_region { diff --git a/crates/halo/src/waveform/zoomed.rs b/crates/halo/src/waveform/zoomed.rs index d0c312b..1a4ec0b 100644 --- a/crates/halo/src/waveform/zoomed.rs +++ b/crates/halo/src/waveform/zoomed.rs @@ -17,6 +17,9 @@ const TICK_BEAT_PX: f32 = 8.0; const TICK_DOWNBEAT_PX: f32 = 14.0; /// Scroll distance (points) per zoom step on wheel/trackpad zoom. const SCROLL_PER_ZOOM_STEP: f32 = 40.0; +/// Cue marker triangle size in points. +const CUE_TRI_W: f32 = 9.0; +const CUE_TRI_H: f32 = 7.0; /// Zoom presets: bars when a grid exists, seconds otherwise. Same index /// into both tables so toggling grids keeps a comparable span. @@ -101,6 +104,13 @@ pub enum ScrubGesture { Release, } +/// Translucent ghost playhead for the sync-align slide-in: drawn +/// `offset_frames` from the centered playhead, fading with `alpha`. +pub struct GhostPlayhead { + pub offset_frames: f64, + pub alpha: f32, +} + pub struct ZoomedParams<'a> { pub peaks: Option<&'a BandPeaks>, pub marks: &'a GridMarks, @@ -109,6 +119,13 @@ pub struct ZoomedParams<'a> { pub sample_rate: u32, pub loop_region: Option<(usize, usize)>, pub loop_in: Option, + /// Hot cue slots (source frames); markers draw at the top edge for each + /// defined slot. Pass `&[]` for views without hot cues. + pub hot_cues: &'a [Option], + /// CDJ cue point (source frames), drawn as an unnumbered marker. + pub cue_point: Option, + /// Sync-align slide-in animation, if one is running. + pub ghost: Option, } /// Paint the zoomed view. Reports the drag lifecycle while the user @@ -213,6 +230,15 @@ pub fn paint_zoomed( .count(); let plan = overlay_plan(rect.width(), visible.len(), downbeats); let stride = plan.downbeat_stride as u32; + // Bar-number labels need more room than ticks (~34 px vs 6), so they + // thin on their own power-of-two stride on top of the tick stride. + let bar_px = (params.marks.median_beat_frames() * 4.0 * map.px_per_frame()) as f32; + let mut label_stride = stride; + if bar_px > 0.0 { + while bar_px * (label_stride as f32) < 34.0 && label_stride < (1 << 16) { + label_stride *= 2; + } + } for i in visible { let is_downbeat = params.marks.is_downbeat(i); let (height, stroke) = if is_downbeat { @@ -220,6 +246,15 @@ pub fn paint_zoomed( if bar == 0 || !(bar - 1).is_multiple_of(stride) { continue; } + if (bar - 1).is_multiple_of(label_stride) { + painter.text( + egui::pos2(map.x(params.marks.frame(i)) + 3.0, rect.top() + 1.0), + egui::Align2::LEFT_TOP, + bar, + egui::FontId::monospace(9.0), + palette::TEXT_DIM, + ); + } ( TICK_DOWNBEAT_PX, egui::Stroke::new(2.0_f32, palette::TICK_DOWNBEAT), @@ -248,6 +283,49 @@ pub fn paint_zoomed( } } + // Elastic lead-in: mark where the track actually starts. Gated on a + // negative position so ordinary near-head playback (where the viewport + // routinely straddles frame 0) stays unadorned. + if params.position_frames < 0.0 { + let x = map.x(0.0); + if x >= rect.left() && x <= rect.right() { + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(1.0_f32, palette::TEXT_DIM), + ); + } + } + + // Cue markers: the CDJ cue point (unnumbered) plus the hot cue slots, + // drawn over the ticks but under the playhead. + let draw_marker = |frame: usize, label: Option| { + let x = map.x(frame as f64); + if x >= rect.left() && x <= rect.right() { + cue_marker(&painter, rect, x, label); + } + }; + if let Some(frame) = params.cue_point { + draw_marker(frame, None); + } + for (slot, cue) in params.hot_cues.iter().enumerate() { + if let Some(frame) = cue { + draw_marker(*frame, Some(slot + 1)); + } + } + + // Ghost playhead: the pre-align position gliding into the centered + // playhead after a sync-aligned start. Drawn center-relative so it + // converges exactly, and under the real playhead so it merges into it. + if let Some(g) = ¶ms.ghost { + let x = rect.center().x + (g.offset_frames * map.px_per_frame()) as f32; + if x >= rect.left() && x <= rect.right() { + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD.gamma_multiply(g.alpha)), + ); + } + } + // Fixed centered playhead — the one full-height line in this view. let center_x = rect.center().x; painter.line_segment( @@ -273,3 +351,33 @@ pub fn paint_zoomed( } None } + +/// CDJ-style cue marker: a down-pointing triangle hanging from the top +/// edge, a small square foot on the bottom edge at the same x, and an +/// optional hot-cue slot number beside the triangle. +fn cue_marker(painter: &egui::Painter, rect: egui::Rect, x: f32, label: Option) { + let color = palette::CUE_MARKER; + painter.add(egui::Shape::convex_polygon( + vec![ + egui::pos2(x - CUE_TRI_W / 2.0, rect.top()), + egui::pos2(x + CUE_TRI_W / 2.0, rect.top()), + egui::pos2(x, rect.top() + CUE_TRI_H), + ], + color, + egui::Stroke::NONE, + )); + painter.rect_filled( + egui::Rect::from_center_size(egui::pos2(x, rect.bottom() - 2.0), egui::vec2(4.0, 4.0)), + 0.0, + color, + ); + if let Some(n) = label { + painter.text( + egui::pos2(x + CUE_TRI_W / 2.0 + 2.0, rect.top()), + egui::Align2::LEFT_TOP, + n, + egui::FontId::proportional(8.0), + color, + ); + } +} From 836748b8bdfc4d7bd39f9cc4277908f8dea38bb6 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 27 Jul 2026 06:59:27 +0800 Subject: [PATCH 3/6] Tidy deck transport/loop button layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uniform 30px height across play, cue, nudge, and all loop buttons; play button gets a constant green border to match the amber cue outline. Loop row is now IN/OUT/÷2/4 BEATS/×2/EXIT with the beats chip centered, and every loop button except the chip is a fixed 45px wide. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/halo/src/app.rs | 51 +++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index a3434c3..1056604 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -3521,12 +3521,16 @@ fn deck_panel( let play_label = if playing { "⏸" } else { "▶" }; if ui .add_sized( - [50.0, 36.0], + [70.0, 30.0], egui::Button::new( egui::RichText::new(play_label) .size(18.0) .color(egui::Color32::from_rgb(90, 220, 120)), ) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(90, 220, 120), + )) .fill(egui::Color32::from_rgb(32, 56, 40)), ) .clicked() @@ -3536,7 +3540,7 @@ fn deck_panel( // CUE — CDJ semantics on press/release edges; amber outline. let cue_resp = ui.add_sized( - [50.0, 36.0], + [70.0, 30.0], egui::Button::new(egui::RichText::new("CUE").size(15.0).color(ACCENT)) .stroke(egui::Stroke::new(1.0, ACCENT)) .fill(ACCENT_FILL), @@ -3559,10 +3563,10 @@ fn deck_panel( control_group(ui, "NUDGE", |ui| { // Pitch bend: momentary ±4% while held. let bend_minus = ui - .add_sized([28.0, 36.0], egui::Button::new("−")) + .add_sized([28.0, 30.0], egui::Button::new("−")) .is_pointer_button_down_on(); let bend_plus = ui - .add_sized([28.0, 36.0], egui::Button::new("+")) + .add_sized([28.0, 30.0], egui::Button::new("+")) .is_pointer_button_down_on(); deck_ui.bend = if bend_minus { 0.96 @@ -3581,18 +3585,22 @@ fn deck_panel( control_group(ui, "LOOP", |ui| { // Loops: manual in/out, quantized autoloop at the shown // length, halve/double between 1/16 and 16 beats (gapless - // feed-thread re-anchor). Fixed height, text-tight width so - // the row stays compact. + // feed-thread re-anchor). Every button is a fixed 45×30 to + // match the play/cue height; only the 4 BEATS chip is wider. let has_loop = loop_region.is_some(); let grid_ok = deck_ui.marks.is_usable(); - let btn = - |txt: &str| egui::Button::new(txt.to_string()).min_size(egui::vec2(0.0, 36.0)); + let btn = |ui: &mut egui::Ui, txt: &str, enabled: bool| { + ui.add_enabled_ui(enabled, |ui| { + ui.add_sized([45.0, 30.0], egui::Button::new(txt.to_string())) + }) + .inner + }; - if ui.add(btn("IN")).clicked() && has_track { + if btn(ui, "IN", true).clicked() && has_track { deck_ui.loop_in_staged = Some(quantize_frame(&deck_ui.marks, deck_ui.quantize, playhead)); } - if ui.add(btn("OUT")).clicked() + if btn(ui, "OUT", true).clicked() && has_track && let Some(start) = deck_ui.loop_in_staged { @@ -3609,33 +3617,34 @@ fn deck_panel( } } + if btn(ui, "÷2", has_loop).clicked() + && let Some((start, _)) = loop_region + { + deck_ui.loop_beats = (deck_ui.loop_beats / 2.0).max(0.0625); + let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); + shared.set_loop(Some((start, end.max(start + 1)))); + } + // Length chip: autoloop at the shown length; lit while a - // loop is active. + // loop is active. Sits in the middle, between ÷2 and ×2. let chip = ui .add_enabled_ui(grid_ok && has_track, |ui| { let label = format!("{} BEATS", format_beats(deck_ui.loop_beats)); - outlined_toggle(ui, has_loop, &label, [72.0, 36.0]) + outlined_toggle(ui, has_loop, &label, [72.0, 30.0]) }) .inner; if chip.on_hover_text("Autoloop at this length").clicked() { deck_ui.autoloop(deck_ui.loop_beats); } - if ui.add_enabled(has_loop, btn("÷2")).clicked() - && let Some((start, _)) = loop_region - { - deck_ui.loop_beats = (deck_ui.loop_beats / 2.0).max(0.0625); - let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); - shared.set_loop(Some((start, end.max(start + 1)))); - } - if ui.add_enabled(has_loop, btn("×2")).clicked() + if btn(ui, "×2", has_loop).clicked() && let Some((start, _)) = loop_region { deck_ui.loop_beats = (deck_ui.loop_beats * 2.0).min(16.0); let end = loop_end_for(&deck_ui.marks, start, deck_ui.loop_beats); shared.set_loop(Some((start, end.max(start + 1)))); } - if ui.add_enabled(has_loop, btn("EXIT")).clicked() { + if btn(ui, "EXIT", has_loop).clicked() { shared.set_loop(None); } }); From f8e1201416eb92412228aa9bb4fa15b044fe9e6e Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 27 Jul 2026 07:15:02 +0800 Subject: [PATCH 4/6] Lighten deck section captions when the deck is live Section captions (TRANSPORT, NUDGE, LOOP, HOT CUES, GATE, QUANTIZE, AUTO CUE) render in a lighter grey when the enclosing scope is enabled, falling back to the dim weak color when no track is loaded. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/halo/src/app.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index 1056604..b295812 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -4113,7 +4113,15 @@ fn process_cpu_secs() -> f64 { /// Small uppercase weak caption above a control group ("TRANSPORT", …). fn section_caption(ui: &mut egui::Ui, text: &str) { - ui.label(egui::RichText::new(text).size(9.0).weak()); + // Lighter grey when the deck is live; falls back to the dim weak color + // when the enclosing scope is disabled (no track loaded). + let text = egui::RichText::new(text).size(9.0); + let text = if ui.is_enabled() { + text.color(egui::Color32::from_gray(170)) + } else { + text.weak() + }; + ui.label(text); } /// Caption above a horizontal control row; returns the row's inner value. From e3d2948bdb1c9e55537dd58ac968418c9377db03 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 27 Jul 2026 07:57:47 +0800 Subject: [PATCH 5/6] Move BPM readout into the deck header BPM now sits in the header to the right of the elapsed/remaining time (with the toggle between them) instead of atop the sidebar. It renders amber when the deck is the master tempo reference and white otherwise; the sidebar starts at KEYLOCK and the pitch fader fills the freed space. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/halo/src/app.rs | 103 +++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index b295812..ce576ce 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -3316,20 +3316,24 @@ fn deck_panel( } } ui.add_space(4.0); - // Time-column width, sized to the widest value ("-" prefix included) - // so the digits don't jitter the layout. - let time_value_w = ui.fonts(|f| { - f.layout_no_wrap( - "-88:88.8".to_owned(), - egui::FontId::monospace(20.0), - egui::Color32::WHITE, - ) - .size() - .x - }); + // Fixed value-column widths, sized to the widest values so the digits + // don't jitter the layout (the "-" prefix only shows on REMAINING). + let value_w = |text: &str| { + ui.fonts(|f| { + f.layout_no_wrap( + text.to_owned(), + egui::FontId::monospace(20.0), + egui::Color32::WHITE, + ) + .size() + .x + }) + }; + let time_value_w = value_w("-88:88.8"); + let bpm_value_w = value_w("888.8"); // Title/artist, width-constrained and truncated so a long name can't - // grow into (and overlap) the right-aligned time readout + toggle. - let title_w = (ui.available_width() - time_value_w - 60.0).max(60.0); + // grow into (and overlap) the right-aligned time + BPM readouts. + let title_w = (ui.available_width() - time_value_w - bpm_value_w - 72.0).max(60.0); ui.allocate_ui_with_layout( egui::vec2(title_w, ARTWORK_SIZE), egui::Layout::top_down(egui::Align::Min), @@ -3352,11 +3356,43 @@ fn deck_panel( } }, ); - // Right side: one time readout — a small caption naming the mode over - // a big white monospace value — with the ⏱ button toggling between - // elapsed and remaining. + // Right side (right-to-left): BPM readout rightmost, then the time + // readout with the ⏱ button toggling elapsed/remaining. Each value is + // a small caption over a big monospace number. BPM is amber while this + // deck is the master tempo reference, white otherwise. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let value_label = |ui: &mut egui::Ui, w: f32, caption: &str, value: String, color| { + ui.allocate_ui_with_layout( + egui::vec2(w, ARTWORK_SIZE), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.add_space(10.0); + section_caption(ui, caption); + ui.label( + egui::RichText::new(value) + .color(color) + .strong() + .monospace() + .size(20.0), + ); + }, + ); + }; + ui.add_space(8.0); + let bpm_text = if deck_ui.bpm > 0.0 && has_track { + format!("{:.1}", deck_ui.bpm * tempo_rate) + } else { + "0.0".to_string() + }; + let bpm_color = if is_master { + ACCENT + } else { + egui::Color32::WHITE + }; + value_label(ui, bpm_value_w, "BPM", bpm_text, bpm_color); + + ui.add_space(12.0); if ui .small_button("⏱") .on_hover_text("Toggle elapsed / remaining") @@ -3376,21 +3412,7 @@ fn deck_panel( } else { ("ELAPSED", format_time(playhead, sample_rate)) }; - ui.allocate_ui_with_layout( - egui::vec2(time_value_w, ARTWORK_SIZE), - egui::Layout::top_down(egui::Align::Min), - |ui| { - ui.add_space(10.0); - section_caption(ui, caption); - ui.label( - egui::RichText::new(value) - .color(egui::Color32::WHITE) - .strong() - .monospace() - .size(20.0), - ); - }, - ); + value_label(ui, time_value_w, caption, value, egui::Color32::WHITE); }); }); ui.add_space(6.0); @@ -3503,7 +3525,6 @@ fn deck_panel( idx, is_master, has_track, - tempo_rate, wave_h, &mut response, ); @@ -3749,7 +3770,6 @@ fn deck_sidebar( deck_idx: usize, is_master: bool, has_track: bool, - tempo_rate: f64, height: f32, response: &mut DeckPanelResponse, ) { @@ -3766,23 +3786,6 @@ fn deck_sidebar( let full = ui.available_width(); - // BPM stays readable even with no track loaded, so it sits outside the - // disabled scope below. - ui.vertical_centered(|ui| { - section_caption(ui, "BPM"); - let bpm_text = if deck_ui.bpm > 0.0 && has_track { - format!("{:.1}", deck_ui.bpm * tempo_rate) - } else { - "0.0".to_string() - }; - ui.label( - egui::RichText::new(bpm_text) - .monospace() - .size(16.0) - .color(ACCENT), - ); - }); - ui.add_enabled_ui(has_track, |ui| { ui.add_space(4.0); ui.vertical_centered(|ui| section_caption(ui, "KEYLOCK")); From 36fe083ba1c208bb739d54777cdb54f27bdf63b1 Mon Sep 17 00:00:00 2001 From: Rob Morgan Date: Mon, 27 Jul 2026 08:58:17 +0800 Subject: [PATCH 6/6] feat(lighting): Phase L3 role-lane UX preview (Look / Energy / Accent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fixture-taxonomy trigger lanes (Lighting/Pixels/FX) with the Phase L3 role lanes in both views, as an interactive session-only preview: - show_preview.rs: LookLane (beat-snapped events, hold-until-next), EnergyLane (piecewise-linear breakpoint envelope), accents on CueSet, mock 8-look palette, typed ShowSel selection - simulate_show_l3: deterministic seed per track — look events at phrase boundaries, intro→drop→outro energy arc, drop-focused accents - show_strip.rs (Perform, read-only color script with active-look highlight and hollow-on-programmer-override) and show_editor.rs (Prepare: create-then-slide looks, two-axis breakpoint drags, legacy accent gestures); classic lanes.rs/lanes_editor.rs deleted - app wiring: seed on load, sync_show mirroring across players, palette swatch toolbar with arm+reassign, Reset demo show Nothing persists and the DMX path is untouched — the legacy CueSet layer still drives the rig and STORE-from-live, so live output is unchanged. Co-Authored-By: Claude Fable 5 --- ROADMAP.md | 11 +- crates/halo/src/app.rs | 351 +++++++------ crates/halo/src/main.rs | 1 + crates/halo/src/show.rs | 188 +++++++ crates/halo/src/show_preview.rs | 415 +++++++++++++++ crates/halo/src/waveform/lanes.rs | 164 ------ crates/halo/src/waveform/lanes_editor.rs | 444 ---------------- crates/halo/src/waveform/mod.rs | 54 +- crates/halo/src/waveform/show_editor.rs | 633 +++++++++++++++++++++++ crates/halo/src/waveform/show_strip.rs | 274 ++++++++++ 10 files changed, 1772 insertions(+), 763 deletions(-) create mode 100644 crates/halo/src/show_preview.rs delete mode 100644 crates/halo/src/waveform/lanes.rs delete mode 100644 crates/halo/src/waveform/lanes_editor.rs create mode 100644 crates/halo/src/waveform/show_editor.rs create mode 100644 crates/halo/src/waveform/show_strip.rs diff --git a/ROADMAP.md b/ROADMAP.md index a71c24d..e6e3afa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,9 +130,14 @@ One `timestretch::Engine` per deck. The audio callback owns both `EngineProcesso ## Lighting & FX Halo drives show lighting alongside the decks. Current state (branch -`mixer-deck-ui-overhaul`): per-deck trigger lanes (Lighting / Pixels / FX) -under the waveforms; editable per-track cues persisted in the library -(`lighting_cues` table, seconds-based JSON); Prepare/Perform views with an +`mixer-deck-ui-overhaul`): per-deck trigger lanes under the waveforms — +now the **Phase L3 preview** role lanes (Look / Energy / Accent, +`show_preview.rs` + `show_strip.rs`/`show_editor.rs`), interactive and +session-only: seeded per track, edited in Prepare, not persisted, and +not yet wired to the DMX path. The legacy three-lane layer remains +underneath: editable per-track cues persisted in the library +(`lighting_cues` table, seconds-based JSON) still feed the rig and +STORE-from-live; Prepare/Perform views with an independent audition player and a direct-manipulation cue editor; a console-style programmer override layer resolved per lane (Programmer > track cues > off, `programmer::resolve()` as the single diff --git a/crates/halo/src/app.rs b/crates/halo/src/app.rs index ce576ce..e57876e 100644 --- a/crates/halo/src/app.rs +++ b/crates/halo/src/app.rs @@ -20,12 +20,13 @@ use crate::fader::{Fader, Notches}; use crate::knob::{Knob, KnobArc}; use crate::library::{Library, PlaylistRow, SortColumn, TrackRow}; use crate::programmer_ui::{ProgrammerCtx, programmer_panel}; -use crate::show::simulate_show; +use crate::show::simulate_show_l3; +use crate::show_preview::{LOOK_PALETTE, LookId, ShowPreview, ShowSel}; use crate::state::{MixerShared, ScrubPhase, Transport}; use crate::waveform::{ - BandPeaks, EditorInteraction, GhostPlayhead, GridMarks, LanesEditorParams, LanesParams, - OverviewParams, OverviewTexture, ScrubGesture, ZoomSpan, ZoomedParams, lanes_editor, - paint_beat_counter, paint_lanes, paint_overview, paint_zoomed, + BandPeaks, GhostPlayhead, GridMarks, OverviewParams, OverviewTexture, ScrubGesture, + ShowEditorInteraction, ShowEditorParams, ShowStripParams, ZoomSpan, ZoomedParams, + paint_beat_counter, paint_overview, paint_show_strip, paint_zoomed, show_editor, }; use crate::worker::{WorkerEvent, spawn_analysis_worker, spawn_folder_import}; @@ -103,9 +104,13 @@ struct DeckUi { pending_artifact: Option>, peaks: Option, marks: GridMarks, - /// Lighting/pixels/FX cues for the lane strip, loaded from the - /// library on track load (empty until authored in Prepare). + /// Lighting/pixels/FX cues, loaded from the library on track load. + /// No longer painted (the L3 strip replaced the classic lanes) but + /// still the layer that feeds the DMX engine and STORE-from-live. cues: CueSet, + /// Session-only L3 show preview (look / energy / accent lanes), + /// seeded on track load, edited in Prepare, never persisted. + show: ShowPreview, bpm: f64, overview: Option, artwork: Option, @@ -179,6 +184,7 @@ impl DeckUi { peaks: None, marks: GridMarks::empty(), cues: CueSet::empty(), + show: ShowPreview::default(), bpm: 0.0, overview: None, artwork: None, @@ -430,10 +436,12 @@ const AUDITION_VOLUME_DEFAULT: f32 = 0.85; /// plus the lane editor's selection and drag state. struct PrepareState { audition: DeckUi, - selection: std::collections::HashSet, - interaction: EditorInteraction, + selection: std::collections::HashSet, + interaction: ShowEditorInteraction, /// Snap editor gestures to the beat grid. snap: bool, + /// Palette look new look events are created with. + armed_look: LookId, } impl PrepareState { @@ -446,21 +454,13 @@ impl PrepareState { Self { audition, selection: std::collections::HashSet::new(), - interaction: EditorInteraction::default(), + interaction: ShowEditorInteraction::default(), snap: true, + armed_look: LookId(0), } } } -/// One clipboard cue; offsets are relative to the earliest copied cue and -/// in seconds, so pastes land correctly on any track at any device rate. -struct ClipCue { - lane: Lane, - offset_secs: f64, - dur_secs: f64, - intensity: f32, -} - const PERSIST_KEY: &str = "halo"; pub struct HaloApp { @@ -473,8 +473,6 @@ pub struct HaloApp { lighting_deck: usize, view: View, prepare: PrepareState, - /// Cue clipboard (survives track switches → cross-track paste). - cue_clipboard: Vec, /// Live manual-override layer; beats the active deck's track cues. programmer: Programmer, /// Which pane the footer shows (library browser or programmer). @@ -647,7 +645,6 @@ impl HaloApp { lighting_deck: 0, view: persisted.view, prepare, - cue_clipboard: Vec::new(), programmer: Programmer::default(), footer_tab: persisted.footer_tab, rig, @@ -824,9 +821,9 @@ impl HaloApp { /// or the Prepare audition player) and kick off background pre-analysis. fn poll_decodes(&mut self, ctx: &egui::Context) { let device_rate = self.device_rate(); - for (i, deck_ui) in self.decks.iter_mut().enumerate() { + for i in 0..self.decks.len() { if let Some(status) = Self::poll_deck_decode( - deck_ui, + &mut self.decks[i], DECK_NAMES[i], ctx, device_rate, @@ -834,6 +831,14 @@ impl HaloApp { &self.wake_tx, ) { self.status = status; + // The show preview is session-only: a fresh load adopts + // any edits a sibling player holds for the same track, + // instead of keeping its own fresh seed. + if self.decks[i].track_id.is_some() + && self.decks[i].track_id == self.prepare.audition.track_id + { + self.decks[i].show = self.prepare.audition.show.clone(); + } } } if let Some(status) = Self::poll_deck_decode( @@ -845,6 +850,13 @@ impl HaloApp { &self.wake_tx, ) { self.status = status; + if let Some(deck) = self + .decks + .iter() + .find(|d| d.track_id.is_some() && d.track_id == self.prepare.audition.track_id) + { + self.prepare.audition.show = deck.show.clone(); + } } } @@ -891,6 +903,16 @@ impl HaloApp { .and_then(|id| library?.cues(id).ok().flatten()) .map(|f| CueSet::from_file(&f, device_rate)) .unwrap_or_else(CueSet::empty); + // Session-only L3 preview: seed the role lanes + // deterministically per track so both views show + // content immediately (poll_decodes adopts edits + // from a sibling player holding the same track). + deck_ui.show = simulate_show_l3( + &deck_ui.marks, + deck_ui.deck.shared.total(), + device_rate, + data.track_id.unwrap_or(1) as u64, + ); deck_ui.bpm = data.grid.bpm; deck_ui.title = data.title; deck_ui.artist = data.artist; @@ -1251,83 +1273,36 @@ impl HaloApp { } } - /// Prepare-view editor keys: Delete removes the selection, ⌘C/⌘V copy - /// and paste (across tracks — the clipboard is app-level). Esc is - /// handled by the caller, layered with programmer CLEAR. + /// Prepare-view editor keys: Delete removes the typed selection + /// (look events, energy breakpoints, accents). Esc is handled by the + /// caller, layered with programmer CLEAR. Cue copy/paste was dropped + /// with the classic lanes; it returns with the full L3 landing. fn prepare_editor_keys(&mut self, ctx: &egui::Context) { use egui::Key; - let (del, copy, paste) = ctx.input(|i| { - ( - i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace), - i.modifiers.command && i.key_pressed(Key::C), - i.modifiers.command && i.key_pressed(Key::V), - ) - }); - if !(del || copy || paste) { - return; - } - let sr = self.device_rate().max(1) as f64; - let mut mutated = false; + let del = ctx.input(|i| i.key_pressed(Key::Delete) || i.key_pressed(Key::Backspace)); let PrepareState { audition, selection, - snap, .. } = &mut self.prepare; - let track_id = audition.track_id; - - if copy && !selection.is_empty() { - let mut items: Vec<(Lane, f64, f64, f32)> = selection - .iter() - .filter_map(|&id| { - audition - .cues - .find(id) - .map(|(l, c)| (l, c.start_frame, c.duration_frames, c.intensity)) - }) - .collect(); - items.sort_by(|a, b| a.1.total_cmp(&b.1)); - if let Some(&(_, first, _, _)) = items.first() { - self.cue_clipboard = items - .iter() - .map(|&(lane, start, dur, intensity)| ClipCue { - lane, - offset_secs: (start - first) / sr, - dur_secs: dur / sr, - intensity, - }) - .collect(); - } + if !del || selection.is_empty() { + return; } - if paste && !self.cue_clipboard.is_empty() && audition.deck.track.is_some() { - let playhead = audition.deck.shared.playhead_frames() as f64; - let base = crate::waveform::snap_frame(&audition.marks, *snap, playhead); - selection.clear(); - for clip in &self.cue_clipboard { - if let Some(id) = audition.cues.insert( - clip.lane, - base + clip.offset_secs * sr, - clip.dur_secs * sr, - clip.intensity, - ) { - selection.insert(id); + let mut accents = std::collections::HashSet::new(); + for &sel in selection.iter() { + match sel { + ShowSel::Look(id) => audition.show.looks.remove(id), + ShowSel::Energy(id) => audition.show.energy.remove(id), + ShowSel::Accent(id) => { + accents.insert(id); } } - mutated = true; - } - if del && !selection.is_empty() { - audition.cues.remove(selection); - selection.clear(); - mutated = true; } - - let dirty = if mutated { - Some(audition.cues.clone()) - } else { - None - }; - if let (Some(cues), Some(id)) = (dirty, track_id) { - self.commit_cues(id, &cues); + audition.show.accents.remove(&accents); + selection.clear(); + let (show, track_id) = (audition.show.clone(), audition.track_id); + if let Some(id) = track_id { + self.sync_show(id, &show); } } @@ -1910,6 +1885,20 @@ impl HaloApp { } } + /// Session-only analogue of [`commit_cues`](Self::commit_cues) for the + /// L3 show preview: propagate an edited show to every player holding + /// the same track. No persistence — the preview dies with the session. + fn sync_show(&mut self, track_id: i64, show: &ShowPreview) { + for d in &mut self.decks { + if d.track_id == Some(track_id) { + d.show = show.clone(); + } + } + if self.prepare.audition.track_id == Some(track_id) { + self.prepare.audition.show = show.clone(); + } + } + /// The Prepare view's central panel: audition transport plus the same /// waveform stack as a deck, with the direct-manipulation cue-lane /// editor in the middle. @@ -1921,6 +1910,7 @@ impl HaloApp { selection, interaction, snap, + armed_look, } = &mut self.prepare; let shared = audition.deck.shared.clone(); let has_track = audition.deck.track.is_some(); @@ -2056,22 +2046,23 @@ impl HaloApp { ); ui.add_space(2.0); - let mut mutated = lanes_editor( + let mut mutated = show_editor( ui, - LanesEditorParams { + ShowEditorParams { marks: &audition.marks, position_frames: display_pos, total_frames: total, sample_rate, snap: *snap, + armed_look: *armed_look, }, &audition.zoom, - &mut audition.cues, + &mut audition.show, selection, interaction, ); - // Inspector row: snap, selection tools, generate/clear. + // Inspector row: snap, look palette, selection tools, reset/clear. ui.add_space(4.0); ui.add_enabled_ui(has_track, |ui| { ui.horizontal(|ui| { @@ -2083,75 +2074,137 @@ impl HaloApp { *snap = !*snap; } ui.separator(); - let count = selection.len(); - if count == 0 { + // Look palette: click arms the look for new events and + // reassigns any selected look events. + let selected_looks: Vec = selection + .iter() + .filter_map(|&s| match s { + ShowSel::Look(id) => Some(id), + _ => None, + }) + .collect(); + for (i, look) in LOOK_PALETTE.iter().enumerate() { + let armed = armed_look.0 == i; + let mut swatch = egui::Button::new("") + .fill(look.color.gamma_multiply(0.85)) + .min_size(egui::vec2(18.0, 18.0)); + if armed { + swatch = swatch.stroke(egui::Stroke::new(2.0_f32, egui::Color32::WHITE)); + } + let resp = ui.add(swatch).on_hover_text(look.name); + if resp.clicked() { + *armed_look = LookId(i); + if !selected_looks.is_empty() { + for &id in &selected_looks { + audition.show.looks.set_look(id, LookId(i)); + } + mutated = true; + } + } + } + ui.separator(); + let selected_accents: std::collections::HashSet = selection + .iter() + .filter_map(|&s| match s { + ShowSel::Accent(id) => Some(id), + _ => None, + }) + .collect(); + if selection.is_empty() { ui.label( egui::RichText::new( - "Drag in a lane to draw a cue · drag edges to resize · ⌘-drag to \ - rubber-band select", + "Drag in LOOK to place the armed look · drag in ENERGY to shape \ + the arc · draw one-shots in ACCENT", ) .weak() .size(11.0), ); } else { - ui.label( - egui::RichText::new(format!("{count} selected")) - .size(11.0) - .strong(), - ); - let mut intensity = selection - .iter() - .next() - .and_then(|&id| audition.cues.find(id)) - .map(|(_, c)| c.intensity) - .unwrap_or(1.0); - let resp = ui - .add( - egui::Slider::new(&mut intensity, 0.0..=1.0) - .show_value(false) - .text("INT"), - ) - .on_hover_text("Intensity of the selected cue(s)"); - if resp.changed() { - for &id in selection.iter() { - audition.cues.set_intensity(id, intensity); - } + if let [id] = selected_looks[..] + && let Some(ev) = audition.show.looks.find(id) + { + ui.label( + egui::RichText::new(ev.look.def().name) + .size(11.0) + .color(ev.look.def().color) + .strong(), + ); + } else { + ui.label( + egui::RichText::new(format!("{} selected", selection.len())) + .size(11.0) + .strong(), + ); } - if resp.drag_stopped() { - mutated = true; + if !selected_accents.is_empty() { + let mut intensity = selected_accents + .iter() + .next() + .and_then(|&id| audition.show.accents.find(id)) + .map(|(_, c)| c.intensity) + .unwrap_or(1.0); + let resp = ui + .add( + egui::Slider::new(&mut intensity, 0.0..=1.0) + .show_value(false) + .text("INT"), + ) + .on_hover_text("Intensity of the selected accent(s)"); + if resp.changed() { + for &id in &selected_accents { + audition.show.accents.set_intensity(id, intensity); + } + } + if resp.drag_stopped() { + mutated = true; + } } if ui.button("Delete").clicked() { - audition.cues.remove(selection); + for &s in selection.iter() { + match s { + ShowSel::Look(id) => audition.show.looks.remove(id), + ShowSel::Energy(id) => audition.show.energy.remove(id), + ShowSel::Accent(_) => {} + } + } + audition.show.accents.remove(&selected_accents); selection.clear(); mutated = true; } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.menu_button("Clear ▾", |ui| { - for (lane, name) in [ - (Lane::Lighting, "Lighting"), - (Lane::Pixels, "Pixels"), - (Lane::Fx, "FX"), - ] { - if ui.button(name).clicked() { - audition.cues.clear_lane(lane); - mutated = true; - ui.close_menu(); - } + if ui.button("Looks").clicked() { + audition.show.looks.clear(); + mutated = true; + ui.close_menu(); + } + if ui.button("Energy").clicked() { + audition.show.energy.clear(); + mutated = true; + ui.close_menu(); } - if ui.button("All lanes").clicked() { - audition.cues = CueSet::empty(); + if ui.button("Accents").clicked() { + audition.show.accents = CueSet::empty(); + mutated = true; + ui.close_menu(); + } + if ui.button("All").clicked() { + audition.show = ShowPreview::default(); selection.clear(); mutated = true; ui.close_menu(); } }); if ui - .button("Generate demo cues") - .on_hover_text("Seed the track with simulated beat-aligned cues, then edit") + .button("Reset demo show") + .on_hover_text( + "Re-seed the look / energy / accent lanes with the simulated \ + show, then edit", + ) .clicked() { - audition.cues = simulate_show( + audition.show = simulate_show_l3( &audition.marks, total, sample_rate, @@ -2223,16 +2276,17 @@ impl HaloApp { } } - // Autosave: every completed mutation lands in the library and - // propagates to any deck holding the same track. + // Session sync: every completed mutation propagates to any deck + // holding the same track (no persistence — the L3 preview is + // session-only). let track_id = audition.track_id; let dirty = if mutated { - Some(audition.cues.clone()) + Some(audition.show.clone()) } else { None }; - if let (Some(cues), Some(id)) = (dirty, track_id) { - self.commit_cues(id, &cues); + if let (Some(show), Some(id)) = (dirty, track_id) { + self.sync_show(id, &show); } if let Some(id) = dropped { self.load_audition(id); @@ -3458,19 +3512,20 @@ fn deck_panel( sample_rate, ); - // Lighting / Pixels / FX trigger lanes, scrolling in lockstep - // with the zoomed view above. + // L3 show lanes (look / energy / accent), scrolling in + // lockstep with the zoomed view above. ui.add_space(2.0); - paint_lanes( + paint_show_strip( ui, - LanesParams { - cues: &deck_ui.cues, + ShowStripParams { + show: &deck_ui.show, marks: &deck_ui.marks, position_frames: display_pos, total_frames: total, sample_rate, lighting_active: is_lighting, - outputs: lighting_outputs, + programmer_override: lighting_outputs + .is_some_and(|o| o.iter().any(|l| l.source == LaneSource::Programmer)), }, &deck_ui.zoom, ); diff --git a/crates/halo/src/main.rs b/crates/halo/src/main.rs index 996724d..24813ae 100644 --- a/crates/halo/src/main.rs +++ b/crates/halo/src/main.rs @@ -10,6 +10,7 @@ mod library; mod programmer_ui; mod scrub; mod show; +mod show_preview; mod state; mod waveform; mod worker; diff --git a/crates/halo/src/show.rs b/crates/halo/src/show.rs index 3ede563..e1dc868 100644 --- a/crates/halo/src/show.rs +++ b/crates/halo/src/show.rs @@ -6,6 +6,7 @@ use halo_light::cues::{CueSet, Lane}; +use crate::show_preview::{ACCENT_LANE, LookId, ShowPreview}; use crate::waveform::GridMarks; /// splitmix64: a tiny deterministic PRNG so the simulation needs no `rand` @@ -59,6 +60,9 @@ impl BeatSeq { /// SIMULATED show generator: deterministic per `(grid, seed)`, aligned to /// phrases and bars so the bars land musically. +// Legacy three-lane generator, kept (unit-tested) for the full L3 +// landing; the preview seeds `simulate_show_l3` instead. +#[allow(dead_code)] pub fn simulate_show( marks: &GridMarks, total_frames: usize, @@ -122,6 +126,116 @@ pub fn simulate_show( show } +/// Linear interpolation for the energy arc segments. +fn lerp(a: f32, b: f32, t: f64) -> f32 { + a + (b - a) * t.clamp(0.0, 1.0) as f32 +} + +/// Target energy for a section at normalized set position `t` (0..1): +/// intro → build → drop → breakdown → second build → outro. +fn arc_target(t: f64) -> f32 { + if t < 0.15 { + 0.35 + } else if t < 0.35 { + lerp(0.4, 0.8, (t - 0.15) / 0.20) + } else if t < 0.55 { + 1.0 + } else if t < 0.70 { + 0.45 + } else if t < 0.85 { + lerp(0.6, 0.95, (t - 0.70) / 0.15) + } else { + 0.30 + } +} + +/// SIMULATED L3 show: deterministic per `(grid, seed)`. Look events at +/// phrase boundaries following a cool/hot palette split, an energy arc +/// (intro → build → drop → breakdown → outro), and sparse accents. +/// Session-only seed content for the role-lane preview. +pub fn simulate_show_l3( + marks: &GridMarks, + total_frames: usize, + sample_rate: u32, + seed: u64, +) -> ShowPreview { + if total_frames == 0 { + return ShowPreview::default(); + } + let seq = if marks.is_usable() && marks.median_beat_frames() > 0.0 { + BeatSeq::from_marks(marks) + } else { + BeatSeq::synthetic(total_frames, sample_rate) + }; + if seq.frames.len() < 2 { + return ShowPreview::default(); + } + + let beat = seq.beat_frames; + let bar = 4.0 * beat; + let total = total_frames as f64; + + // Section boundaries: phrase starts, falling back to every 8th + // downbeat, then every 32nd beat, so short/gridless tracks still + // get an arc. + let mut sections: Vec = (0..seq.frames.len()) + .filter(|&i| seq.phrase_start[i]) + .map(|i| seq.frames[i]) + .collect(); + if sections.len() < 2 { + sections = (0..seq.frames.len()) + .filter(|&i| seq.downbeat[i]) + .step_by(8) + .map(|i| seq.frames[i]) + .collect(); + } + if sections.len() < 2 { + sections = seq.frames.iter().copied().step_by(32).collect(); + } + if sections.is_empty() { + sections = vec![seq.frames[0]]; + } + + // Palette pools by temperature; indices into LOOK_PALETTE. + const COOL: [usize; 4] = [1, 7, 0, 5]; // Deep Blue, UV Violet, Warm Open, Magenta Chase + const HOT: [usize; 4] = [2, 3, 4, 6]; // Red Drop, Strobe White, Acid Green, Amber Sweep + + let mut show = ShowPreview::default(); + let mut rng = seed; + let mut prev_energy: f32 = 0.0; + let mut prev_look: Option = None; + + for (i, &frame) in sections.iter().enumerate() { + let t = i as f64 / sections.len() as f64; + let target = arc_target(t); + let energy = (target + (rand_f32(&mut rng) - 0.5) * 0.1).clamp(0.0, 1.0); + show.energy.insert(frame, energy); + + let pool = if target < 0.6 { &COOL } else { &HOT }; + let mut look = pool[(rand_f32(&mut rng) * pool.len() as f32) as usize % pool.len()]; + if prev_look == Some(look) { + look = pool[(rand_f32(&mut rng) * pool.len() as f32) as usize % pool.len()]; + } + show.looks.insert(frame, LookId(look), bar); + prev_look = Some(look); + + // Accents: a full hit on the drop (energy stepping up past 0.9); + // otherwise ~25% of sections hit the downbeat of their last bar. + if prev_energy < 0.9 && energy >= 0.9 { + show.accents.insert(ACCENT_LANE, frame, beat, 1.0); + } else if rand_f32(&mut rng) < 0.25 { + let next = sections.get(i + 1).copied().unwrap_or(total); + let hit = (next - bar).max(frame); + show.accents + .insert(ACCENT_LANE, hit, beat, 0.6 + 0.4 * rand_f32(&mut rng)); + } + prev_energy = energy; + } + // Closing breakpoint so the outro level holds visibly to the end. + show.energy.insert(total, arc_target(1.0)); + show +} + #[cfg(test)] mod tests { use super::*; @@ -187,6 +301,80 @@ mod tests { assert!(!collect(&show, Lane::Lighting).is_empty()); } + #[test] + fn l3_deterministic_per_seed() { + let marks = test_marks(); + let a = simulate_show_l3(&marks, 256 * 22050, 44100, 7); + let b = simulate_show_l3(&marks, 256 * 22050, 44100, 7); + let looks = |s: &ShowPreview| -> Vec<(f64, usize)> { + s.looks + .events() + .iter() + .map(|e| (e.frame, e.look.0)) + .collect() + }; + let energy = |s: &ShowPreview| -> Vec<(f64, f32)> { + s.energy + .points() + .iter() + .map(|p| (p.frame, p.value)) + .collect() + }; + assert_eq!(looks(&a), looks(&b)); + assert_eq!(energy(&a), energy(&b)); + assert_eq!( + collect(&a.accents, ACCENT_LANE), + collect(&b.accents, ACCENT_LANE) + ); + } + + #[test] + fn l3_looks_sorted_with_bar_separation() { + let marks = test_marks(); + let show = simulate_show_l3(&marks, 256 * 22050, 44100, 42); + let bar = 4.0 * 22050.0; + let ev = show.looks.events(); + assert!(!ev.is_empty()); + for w in ev.windows(2) { + assert!(w[1].frame - w[0].frame >= bar - 1e-6); + } + } + + #[test] + fn l3_energy_in_unit_range_and_strictly_increasing() { + let marks = test_marks(); + let show = simulate_show_l3(&marks, 256 * 22050, 44100, 42); + let pts = show.energy.points(); + assert!(pts.len() >= 2); + for p in pts { + assert!((0.0..=1.0).contains(&p.value)); + } + for w in pts.windows(2) { + assert!(w[1].frame > w[0].frame); + } + } + + #[test] + fn l3_accents_sorted_non_overlapping() { + let marks = test_marks(); + let show = simulate_show_l3(&marks, 256 * 22050, 44100, 42); + let v = collect(&show.accents, ACCENT_LANE); + for w in v.windows(2) { + assert!(w[0].0 + w[0].1 <= w[1].0 + 1e-6, "{w:?}"); + } + } + + #[test] + fn l3_empty_track_is_empty_and_no_grid_falls_back() { + let empty = simulate_show_l3(&GridMarks::empty(), 0, 44100, 1); + assert!(empty.looks.events().is_empty()); + assert!(empty.energy.points().is_empty()); + // 60 s without a grid: synthetic fallback still seeds a show. + let show = simulate_show_l3(&GridMarks::empty(), 60 * 44100, 44100, 1); + assert!(!show.looks.events().is_empty()); + assert!(show.energy.points().len() >= 2); + } + #[test] fn visible_windows_by_start_and_duration() { let marks = test_marks(); diff --git a/crates/halo/src/show_preview.rs b/crates/halo/src/show_preview.rs new file mode 100644 index 0000000..120fee8 --- /dev/null +++ b/crates/halo/src/show_preview.rs @@ -0,0 +1,415 @@ +//! Session-only Phase L3 preview model: the role-based show lanes +//! (look / energy / accent) with a hardcoded mock look palette. +//! +//! This is a UX preview, deliberately kept out of `halo-light`: nothing +//! here is persisted, and nothing here touches the DMX path — the legacy +//! `CueSet` keeps driving the rig. When L3 lands for real, looks become +//! library entities and `resolve()` learns this shape; these types don't +//! pretend to be that domain model. + +use eframe::egui::Color32; +use halo_light::cues::{CueSet, Lane}; + +/// A mock look: name + signature color for the lane blocks and palette. +pub struct LookDef { + pub name: &'static str, + pub color: Color32, +} + +/// Hardcoded palette the preview picks looks from. [`LookId`] indexes it. +pub const LOOK_PALETTE: [LookDef; 8] = [ + LookDef { + name: "Warm Open", + color: Color32::from_rgb(255, 170, 60), + }, + LookDef { + name: "Deep Blue", + color: Color32::from_rgb(60, 110, 235), + }, + LookDef { + name: "Red Drop", + color: Color32::from_rgb(230, 45, 45), + }, + LookDef { + name: "Strobe White", + color: Color32::from_rgb(238, 238, 244), + }, + LookDef { + name: "Acid Green", + color: Color32::from_rgb(120, 225, 70), + }, + LookDef { + name: "Magenta Chase", + color: Color32::from_rgb(240, 60, 190), + }, + LookDef { + name: "Amber Sweep", + color: Color32::from_rgb(250, 205, 40), + }, + LookDef { + name: "UV Violet", + color: Color32::from_rgb(140, 70, 255), + }, +]; + +/// Index into [`LOOK_PALETTE`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LookId(pub usize); + +impl LookId { + pub fn def(self) -> &'static LookDef { + &LOOK_PALETTE[self.0 % LOOK_PALETTE.len()] + } +} + +/// One look change: the rig switches to `look` at `frame` and holds until +/// the next event. Ids are runtime-only selection handles. +#[derive(Debug, Clone, Copy)] +pub struct LookEvent { + pub id: u64, + pub frame: f64, + pub look: LookId, +} + +/// Sparse look events. Invariant: sorted by frame; mutators enforce the +/// `min_sep` they're given (the editor passes ~1 beat), clamping rather +/// than rejecting on moves so drags butt their neighbors. +#[derive(Debug, Clone, Default)] +pub struct LookLane { + events: Vec, + next_id: u64, +} + +impl LookLane { + pub fn events(&self) -> &[LookEvent] { + &self.events + } + + fn pos_of(&self, id: u64) -> Option { + self.events.iter().position(|e| e.id == id) + } + + /// Insert an event; `None` when another event sits within `min_sep`. + pub fn insert(&mut self, frame: f64, look: LookId, min_sep: f64) -> Option { + let frame = frame.max(0.0); + if self + .events + .iter() + .any(|e| (e.frame - frame).abs() < min_sep) + { + return None; + } + let id = self.next_id; + self.next_id += 1; + self.events.push(LookEvent { id, frame, look }); + self.events.sort_by(|a, b| a.frame.total_cmp(&b.frame)); + Some(id) + } + + /// Move an event, clamped `min_sep` clear of both neighbors (and ≥ 0). + pub fn move_event(&mut self, id: u64, new_frame: f64, min_sep: f64) { + let Some(i) = self.pos_of(id) else { + return; + }; + let lo = i + .checked_sub(1) + .map_or(0.0, |p| self.events[p].frame + min_sep); + let hi = self + .events + .get(i + 1) + .map_or(f64::INFINITY, |n| n.frame - min_sep); + if lo > hi { + return; // neighbors closer than 2×min_sep: hold position + } + self.events[i].frame = new_frame.clamp(lo.max(0.0), hi.max(0.0)); + } + + pub fn set_look(&mut self, id: u64, look: LookId) { + if let Some(i) = self.pos_of(id) { + self.events[i].look = look; + } + } + + pub fn remove(&mut self, id: u64) { + self.events.retain(|e| e.id != id); + } + + pub fn find(&self, id: u64) -> Option { + self.pos_of(id).map(|i| self.events[i]) + } + + /// Hold semantics: the last event at or before `frame`; `None` before + /// the first event. + pub fn active_at(&self, frame: f64) -> Option<&LookEvent> { + let i = self.events.partition_point(|e| e.frame <= frame); + i.checked_sub(1).map(|i| &self.events[i]) + } + + /// Events whose *block* overlaps `[start, end)` — including the + /// carry-in event that starts before `start` but holds into the + /// window. (Differs from `CueSet::visible`, which windows by explicit + /// durations.) + pub fn visible(&self, start: f64, end: f64) -> &[LookEvent] { + let hi = self.events.partition_point(|e| e.frame < end); + let lo = self.events[..hi] + .partition_point(|e| e.frame <= start) + .saturating_sub(1); + &self.events[lo..hi] + } + + pub fn clear(&mut self) { + self.events.clear(); + } +} + +/// One energy breakpoint. Ids are runtime-only selection handles. +#[derive(Debug, Clone, Copy)] +pub struct Breakpoint { + pub id: u64, + pub frame: f64, + pub value: f32, +} + +/// Minimum frame separation between breakpoints, so the envelope stays a +/// function of time. +const POINT_SEP: f64 = 1.0; + +/// Piecewise-linear energy envelope. Invariant: sorted by frame, strictly +/// increasing. Empty means flat 1.0. +#[derive(Debug, Clone, Default)] +pub struct EnergyLane { + points: Vec, + next_id: u64, +} + +impl EnergyLane { + pub fn points(&self) -> &[Breakpoint] { + &self.points + } + + fn pos_of(&self, id: u64) -> Option { + self.points.iter().position(|p| p.id == id) + } + + /// Insert a breakpoint; a frame collision nudges past the occupant. + pub fn insert(&mut self, frame: f64, value: f32) -> u64 { + let mut frame = frame.max(0.0); + while self + .points + .iter() + .any(|p| (p.frame - frame).abs() < POINT_SEP) + { + frame += POINT_SEP; + } + let id = self.next_id; + self.next_id += 1; + self.points.push(Breakpoint { + id, + frame, + value: value.clamp(0.0, 1.0), + }); + self.points.sort_by(|a, b| a.frame.total_cmp(&b.frame)); + id + } + + /// Move a breakpoint: x clamped between its neighbors, y to 0..=1. + pub fn move_point(&mut self, id: u64, frame: f64, value: f32) { + let Some(i) = self.pos_of(id) else { + return; + }; + let lo = i + .checked_sub(1) + .map_or(0.0, |p| self.points[p].frame + POINT_SEP); + let hi = self + .points + .get(i + 1) + .map_or(f64::INFINITY, |n| n.frame - POINT_SEP); + self.points[i].frame = frame.clamp(lo.max(0.0), hi.max(0.0)); + self.points[i].value = value.clamp(0.0, 1.0); + } + + pub fn remove(&mut self, id: u64) { + self.points.retain(|p| p.id != id); + } + + pub fn find(&self, id: u64) -> Option { + self.pos_of(id).map(|i| self.points[i]) + } + + /// Envelope value at `frame`: 1.0 when empty, flat extension before + /// the first and after the last point, linear in between. + pub fn value_at(&self, frame: f64) -> f32 { + let (Some(first), Some(last)) = (self.points.first(), self.points.last()) else { + return 1.0; + }; + if frame <= first.frame { + return first.value; + } + if frame >= last.frame { + return last.value; + } + let i = self.points.partition_point(|p| p.frame <= frame); + let (a, b) = (&self.points[i - 1], &self.points[i]); + let t = ((frame - a.frame) / (b.frame - a.frame)) as f32; + a.value + (b.value - a.value) * t + } + + pub fn clear(&mut self) { + self.points.clear(); + } +} + +/// The accent lane borrows `CueSet`'s sorted/non-overlap machinery on one +/// designated legacy lane. +pub const ACCENT_LANE: Lane = Lane::Fx; + +/// The full session-only show preview for one track. +#[derive(Debug, Clone, Default)] +pub struct ShowPreview { + pub looks: LookLane, + pub energy: EnergyLane, + /// Only [`ACCENT_LANE`] is populated. + pub accents: CueSet, +} + +/// Typed selection handle — the three lanes have independent id spaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShowSel { + Look(u64), + Energy(u64), + Accent(u64), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frames(lane: &LookLane) -> Vec { + lane.events().iter().map(|e| e.frame).collect() + } + + #[test] + fn look_insert_sorts_and_enforces_min_sep() { + let mut l = LookLane::default(); + assert!(l.insert(300.0, LookId(0), 100.0).is_some()); + assert!(l.insert(0.0, LookId(1), 100.0).is_some()); + // Within min_sep of the event at 300: rejected. + assert!(l.insert(250.0, LookId(2), 100.0).is_none()); + assert_eq!(frames(&l), vec![0.0, 300.0]); + } + + #[test] + fn look_move_clamps_between_neighbors() { + let mut l = LookLane::default(); + let a = l.insert(0.0, LookId(0), 10.0).unwrap(); + let b = l.insert(500.0, LookId(1), 10.0).unwrap(); + let mid = l.insert(200.0, LookId(2), 10.0).unwrap(); + l.move_event(mid, -1000.0, 10.0); + assert_eq!(l.find(mid).unwrap().frame, 10.0); + l.move_event(mid, 1000.0, 10.0); + assert_eq!(l.find(mid).unwrap().frame, 490.0); + // The first event clamps at 0 with no left neighbor. + l.move_event(a, -50.0, 10.0); + assert_eq!(l.find(a).unwrap().frame, 0.0); + // Order still sorted after all the shoving. + let _ = b; + assert!(frames(&l).windows(2).all(|w| w[0] < w[1])); + } + + #[test] + fn look_active_at_holds_until_next() { + let mut l = LookLane::default(); + l.insert(100.0, LookId(3), 1.0); + l.insert(400.0, LookId(5), 1.0); + assert!(l.active_at(50.0).is_none()); + assert_eq!(l.active_at(100.0).unwrap().look, LookId(3)); + assert_eq!(l.active_at(399.0).unwrap().look, LookId(3)); + assert_eq!(l.active_at(400.0).unwrap().look, LookId(5)); + assert_eq!(l.active_at(9_999.0).unwrap().look, LookId(5)); + } + + #[test] + fn look_visible_includes_carry_in() { + let mut l = LookLane::default(); + l.insert(0.0, LookId(0), 1.0); + l.insert(100.0, LookId(1), 1.0); + l.insert(500.0, LookId(2), 1.0); + // Window opens mid-hold of the event at 100: it must be included. + let vis = frames_of(l.visible(200.0, 600.0)); + assert_eq!(vis, vec![100.0, 500.0]); + // Window entirely inside one hold returns just that event. + assert_eq!(frames_of(l.visible(150.0, 160.0)), vec![100.0]); + // Window before everything returns the first event only. + assert_eq!(frames_of(l.visible(-10.0, 50.0)), vec![0.0]); + } + + fn frames_of(events: &[LookEvent]) -> Vec { + events.iter().map(|e| e.frame).collect() + } + + #[test] + fn look_set_look_and_remove() { + let mut l = LookLane::default(); + let a = l.insert(0.0, LookId(0), 1.0).unwrap(); + l.insert(100.0, LookId(1), 1.0); + l.set_look(a, LookId(7)); + assert_eq!(l.find(a).unwrap().look, LookId(7)); + l.remove(a); + assert!(l.find(a).is_none()); + assert_eq!(l.events().len(), 1); + } + + #[test] + fn energy_empty_is_flat_one() { + let e = EnergyLane::default(); + assert_eq!(e.value_at(0.0), 1.0); + assert_eq!(e.value_at(1e9), 1.0); + } + + #[test] + fn energy_interpolates_and_extends_flat() { + let mut e = EnergyLane::default(); + e.insert(100.0, 0.2); + e.insert(300.0, 0.8); + assert!((e.value_at(0.0) - 0.2).abs() < 1e-6); // flat before + assert!((e.value_at(200.0) - 0.5).abs() < 1e-6); // midpoint lerp + assert!((e.value_at(500.0) - 0.8).abs() < 1e-6); // flat after + } + + #[test] + fn energy_move_clamps_x_between_neighbors_and_y_to_unit() { + let mut e = EnergyLane::default(); + e.insert(0.0, 0.5); + let mid = e.insert(200.0, 0.5); + e.insert(400.0, 0.5); + e.move_point(mid, -100.0, 2.0); + let p = e.find(mid).unwrap(); + assert_eq!(p.frame, 1.0); // clamped 1 frame past the left neighbor + assert_eq!(p.value, 1.0); + e.move_point(mid, 1e9, -1.0); + let p = e.find(mid).unwrap(); + assert_eq!(p.frame, 399.0); + assert_eq!(p.value, 0.0); + } + + #[test] + fn energy_insert_nudges_off_duplicate_frames() { + let mut e = EnergyLane::default(); + e.insert(100.0, 0.1); + e.insert(100.0, 0.9); + let f: Vec = e.points().iter().map(|p| p.frame).collect(); + assert!(f.windows(2).all(|w| w[1] - w[0] >= POINT_SEP)); + } + + #[test] + fn show_preview_default_is_empty() { + let s = ShowPreview::default(); + assert!(s.looks.events().is_empty()); + assert!(s.energy.points().is_empty()); + assert!( + s.accents + .visible(ACCENT_LANE, f64::MIN, f64::MAX) + .is_empty() + ); + } +} diff --git a/crates/halo/src/waveform/lanes.rs b/crates/halo/src/waveform/lanes.rs deleted file mode 100644 index 7f2c7d4..0000000 --- a/crates/halo/src/waveform/lanes.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Trigger lanes: three rows (Lighting / Pixels / FX) under the zoomed -//! waveform, sharing its frame→x mapping and centered playhead so the -//! bars scroll in lockstep with the audio. - -use eframe::egui; -use halo_light::cues::{CueSet, LANE_COUNT}; -use halo_light::programmer::{LaneOutput, LaneSource}; - -use super::zoomed::ZoomSpan; -use super::{FrameMap, GridMarks, LANES, palette}; - -/// Height of each lane row in points. -const LANE_ROW_H: f32 = 14.0; -/// Full strip height: three rows plus two 1 pt separators. -const STRIP_HEIGHT: f32 = 3.0 * LANE_ROW_H + 2.0; -/// Vertical inset of a trigger bar within its row. -const BAR_INSET_Y: f32 = 2.5; -/// Bars never collapse below this width at wide zooms. -const MIN_BAR_W: f32 = 2.0; -/// Extra dim applied to every lane when the deck isn't driving lighting. -const INACTIVE_DIM: f32 = 0.30; -/// Width of the left label gutter in points. -const LABEL_GUTTER_W: f32 = 30.0; - -pub struct LanesParams<'a> { - pub cues: &'a CueSet, - pub marks: &'a GridMarks, - pub position_frames: f64, - pub total_frames: usize, - pub sample_rate: u32, - /// This deck currently drives the lighting rig; dim everything when - /// false. - pub lighting_active: bool, - /// Resolved lighting output, Some only for the active lighting deck: - /// a programmer-overridden lane tints its row and renders its cue - /// bars hollow ("this would be playing, but you've taken over"). - pub outputs: Option<&'a [LaneOutput; LANE_COUNT]>, -} - -/// Paint the three trigger lanes. -pub fn paint_lanes(ui: &mut egui::Ui, params: LanesParams<'_>, span: &ZoomSpan) { - let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); - let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::hover()); - let rect = response.rect; - let painter = painter.with_clip_rect(rect); - - painter.rect_filled(rect, 4.0, palette::LANE_BG); - - let dim = |color: egui::Color32, alpha: f32| { - let alpha = if params.lighting_active { - alpha - } else { - alpha * INACTIVE_DIM - }; - color.gamma_multiply(alpha) - }; - - let row_rect = |row: usize| { - let top = rect.top() + row as f32 * (LANE_ROW_H + 1.0); - egui::Rect::from_min_size( - egui::pos2(rect.left(), top), - egui::vec2(rect.width(), LANE_ROW_H), - ) - }; - - for row in 1..LANES.len() { - let y = row_rect(row).top() - 0.5; - painter.line_segment( - [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], - egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), - ); - } - - let loaded = params.total_frames > 0; - - if loaded { - let span_frames = span.span_frames(params.marks, params.sample_rate); - let map = FrameMap::new(rect, params.position_frames, span_frames); - - for (row, &(lane, _, color)) in LANES.iter().enumerate() { - let rr = row_rect(row); - let overridden = params - .outputs - .is_some_and(|o| o[row].source == LaneSource::Programmer); - if overridden { - painter.rect_filled(rr, 0.0, color.gamma_multiply(0.10)); - } - for c in params - .cues - .visible(lane, map.start_frame(), map.end_frame()) - { - let x0 = map.x(c.start_frame); - let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); - let bar = egui::Rect::from_min_max( - egui::pos2(x0, rr.top() + BAR_INSET_Y), - egui::pos2(x1, rr.bottom() - BAR_INSET_Y), - ); - let alpha = 0.55 + 0.45 * c.intensity.clamp(0.0, 1.0); - if overridden { - painter.rect_stroke( - bar, - 2.0, - egui::Stroke::new(1.0_f32, dim(color, alpha)), - egui::StrokeKind::Inside, - ); - } else { - painter.rect_filled(bar, 2.0, dim(color, alpha)); - } - } - } - } - - // Left label gutter, painted over the bars so the frame→x mapping stays - // full-width and pixel-identical to the zoomed view above. - let gutter = egui::Rect::from_min_max( - rect.left_top(), - egui::pos2(rect.left() + LABEL_GUTTER_W, rect.bottom()), - ); - painter.rect_filled( - gutter, - egui::CornerRadius { - nw: 4, - ne: 0, - sw: 4, - se: 0, - }, - palette::LANE_BG, - ); - for row in 1..LANES.len() { - let y = row_rect(row).top() - 0.5; - painter.line_segment( - [egui::pos2(gutter.left(), y), egui::pos2(gutter.right(), y)], - egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), - ); - } - painter.line_segment( - [ - egui::pos2(gutter.right() + 0.5, rect.top()), - egui::pos2(gutter.right() + 0.5, rect.bottom()), - ], - egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), - ); - for (row, &(_, label, color)) in LANES.iter().enumerate() { - painter.text( - egui::pos2(gutter.center().x, row_rect(row).center().y), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::monospace(8.0), - dim(color, 0.9), - ); - } - - // Continue the zoomed view's centered playhead through the strip. - if loaded { - let center_x = rect.center().x; - painter.line_segment( - [ - egui::pos2(center_x, rect.top()), - egui::pos2(center_x, rect.bottom()), - ], - egui::Stroke::new(2.0_f32, palette::PLAYHEAD), - ); - } -} diff --git a/crates/halo/src/waveform/lanes_editor.rs b/crates/halo/src/waveform/lanes_editor.rs deleted file mode 100644 index aaef1d2..0000000 --- a/crates/halo/src/waveform/lanes_editor.rs +++ /dev/null @@ -1,444 +0,0 @@ -//! Direct-manipulation editor for a track's cue lanes: tall rows sharing -//! the zoomed view's frame→x mapping, with drag-to-create (snapped to the -//! beat grid), drag-to-move, edge resize, click/shift multi-select, and a -//! Cmd-drag rubber band. All positions are recomputed through the -//! [`FrameMap`] every frame, so editing stays correct while the timeline -//! plays underneath. - -use std::collections::HashSet; - -use eframe::egui; -use halo_light::cues::{ALL_LANES, CueSet}; - -use super::zoomed::ZoomSpan; -use super::{FrameMap, GridMarks, LANES, overlay_plan, palette}; - -/// Editor lane row height in points (~3× the perform strip's rows). -const EDIT_ROW_H: f32 = 44.0; -const STRIP_HEIGHT: f32 = 3.0 * EDIT_ROW_H + 2.0; -/// Pointer distance to a cue edge that counts as a resize grab. -const EDGE_GRAB_PX: f32 = 5.0; -/// Smallest musical cue duration, in beats (0.1 s without a grid). -const MIN_DUR_BEATS: f64 = 0.25; -const BAR_INSET_Y: f32 = 4.0; -const MIN_BAR_W: f32 = 2.0; -/// Intensity of a freshly drawn cue. -const CREATE_INTENSITY: f32 = 0.8; - -pub struct LanesEditorParams<'a> { - pub marks: &'a GridMarks, - pub position_frames: f64, - pub total_frames: usize, - pub sample_rate: u32, - /// Snap creates/moves/resizes to the nearest beat. - pub snap: bool, -} - -/// Drag state carried between frames. -#[derive(Default)] -pub struct EditorInteraction { - drag: Option, -} - -enum DragKind { - /// Draw a new cue: it exists from the first frame and is resized - /// between the anchor and the pointer. - Create { - id: u64, - anchor: f64, - }, - /// Move every selected cue by the pointer delta (original starts are - /// kept so per-frame clamping never accumulates). - Move { - pointer_start: f64, - orig: Vec<(u64, f64)>, - }, - ResizeL { - id: u64, - }, - ResizeR { - id: u64, - }, - RubberBand { - anchor_frame: f64, - anchor_row: usize, - }, -} - -enum Hit { - EdgeL(u64), - EdgeR(u64), - Body(u64), - Empty, -} - -/// Nearest beat when snapping is on (and a grid exists); the raw frame -/// otherwise. Always non-negative. -pub fn snap_frame(marks: &GridMarks, snap: bool, frame: f64) -> f64 { - let frame = frame.max(0.0); - if !snap || !marks.is_usable() { - return frame; - } - match marks.beat_at_or_before(frame) { - Some(i) => { - let a = marks.frame(i); - let b = if i + 1 < marks.len() { - marks.frame(i + 1) - } else { - a - }; - if frame - a <= b - frame { a } else { b } - } - // Before the first beat: the first beat is the only grid point. - None => marks.frame(0).min(frame).max(0.0), - } -} - -/// Paints and edits the lanes in place. Returns `true` when a mutating -/// gesture completed this frame — the caller persists the cue set then. -pub fn lanes_editor( - ui: &mut egui::Ui, - params: LanesEditorParams<'_>, - span: &ZoomSpan, - cues: &mut CueSet, - selection: &mut HashSet, - ix: &mut EditorInteraction, -) -> bool { - let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); - let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click_and_drag()); - let rect = response.rect; - let painter = painter.with_clip_rect(rect); - - painter.rect_filled(rect, 4.0, palette::LANE_BG); - - let row_rect = |row: usize| { - let top = rect.top() + row as f32 * (EDIT_ROW_H + 1.0); - egui::Rect::from_min_size( - egui::pos2(rect.left(), top), - egui::vec2(rect.width(), EDIT_ROW_H), - ) - }; - for row in 1..LANES.len() { - let y = row_rect(row).top() - 0.5; - painter.line_segment( - [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], - egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), - ); - } - - let loaded = params.total_frames > 0; - if !loaded { - for (row, &(_, label, color)) in LANES.iter().enumerate() { - let rr = row_rect(row); - painter.text( - egui::pos2(rr.left() + 6.0, rr.top() + 4.0), - egui::Align2::LEFT_TOP, - label, - egui::FontId::monospace(9.0), - color.gamma_multiply(0.4), - ); - } - return false; - } - - let span_frames = span.span_frames(params.marks, params.sample_rate); - let map = FrameMap::new(rect, params.position_frames, span_frames); - let total = params.total_frames as f64; - let frame_at = |x: f32| map.start_frame() + (x - rect.left()) as f64 / map.px_per_frame(); - let snap = |frame: f64| snap_frame(params.marks, params.snap, frame).min(total); - let min_dur = if params.marks.is_usable() && params.marks.median_beat_frames() > 0.0 { - MIN_DUR_BEATS * params.marks.median_beat_frames() - } else { - 0.1 * params.sample_rate.max(1) as f64 - }; - - let row_at = - |y: f32| (((y - rect.top()) / (EDIT_ROW_H + 1.0)).floor() as isize).clamp(0, 2) as usize; - let hit_test = |cues: &CueSet, pos: egui::Pos2| -> (usize, Hit) { - let row = row_at(pos.y); - let lane = ALL_LANES[row]; - // Edges win over bodies; later (topmost-drawn) cues win ties. - let mut hit = Hit::Empty; - for c in cues.visible(lane, map.start_frame(), map.end_frame()) { - let x0 = map.x(c.start_frame); - let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); - if (pos.x - x0).abs() <= EDGE_GRAB_PX { - hit = Hit::EdgeL(c.id); - } else if (pos.x - x1).abs() <= EDGE_GRAB_PX { - hit = Hit::EdgeR(c.id); - } else if pos.x > x0 && pos.x < x1 { - hit = Hit::Body(c.id); - } - } - (row, hit) - }; - - // Hover cursor feedback (only while not mid-drag). - if ix.drag.is_none() - && let Some(pos) = response.hover_pos() - { - match hit_test(cues, pos).1 { - Hit::EdgeL(_) | Hit::EdgeR(_) => { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - } - Hit::Body(_) => ui.ctx().set_cursor_icon(egui::CursorIcon::Grab), - Hit::Empty => {} - } - } - - let mut committed = false; - let modifiers = ui.input(|i| i.modifiers); - - if response.drag_started() - && let Some(pos) = response.interact_pointer_pos() - { - let (row, hit) = hit_test(cues, pos); - let lane = ALL_LANES[row]; - ix.drag = match hit { - Hit::Body(id) => { - if !selection.contains(&id) { - selection.clear(); - selection.insert(id); - } - let mut orig: Vec<(u64, f64)> = selection - .iter() - .filter_map(|&id| cues.find(id).map(|(_, c)| (id, c.start_frame))) - .collect(); - orig.sort_by(|a, b| a.1.total_cmp(&b.1)); - Some(DragKind::Move { - pointer_start: frame_at(pos.x), - orig, - }) - } - Hit::EdgeL(id) => { - selection.clear(); - selection.insert(id); - Some(DragKind::ResizeL { id }) - } - Hit::EdgeR(id) => { - selection.clear(); - selection.insert(id); - Some(DragKind::ResizeR { id }) - } - Hit::Empty if modifiers.command => Some(DragKind::RubberBand { - anchor_frame: frame_at(pos.x), - anchor_row: row, - }), - Hit::Empty => { - let anchor = snap(frame_at(pos.x)); - cues.insert(lane, anchor, min_dur, CREATE_INTENSITY) - .map(|id| { - selection.clear(); - selection.insert(id); - DragKind::Create { id, anchor } - }) - } - }; - } - - if response.dragged() - && let Some(pos) = response.interact_pointer_pos() - { - match &ix.drag { - Some(DragKind::Create { id, anchor }) => { - let p = snap(frame_at(pos.x)); - let (lo, hi) = if p < *anchor { - (p, *anchor) - } else { - (*anchor, p) - }; - cues.resize(*id, lo, hi.max(lo + min_dur)); - } - Some(DragKind::Move { - pointer_start, - orig, - }) => { - if let Some(&(_, first_start)) = orig.first() { - let raw_delta = frame_at(pos.x) - pointer_start; - let delta = snap(first_start + raw_delta) - first_start; - // Order matters so grouped cues don't clamp against a - // not-yet-moved neighbor: lead with the travel edge. - if delta >= 0.0 { - for &(id, start) in orig.iter().rev() { - cues.move_cue(id, start + delta); - } - } else { - for &(id, start) in orig.iter() { - cues.move_cue(id, start + delta); - } - } - } - } - Some(DragKind::ResizeL { id }) => { - if let Some((_, c)) = cues.find(*id) { - let end = c.end_frame(); - cues.resize(*id, snap(frame_at(pos.x)).min(end - min_dur), end); - } - } - Some(DragKind::ResizeR { id }) => { - if let Some((_, c)) = cues.find(*id) { - let start = c.start_frame; - cues.resize(*id, start, snap(frame_at(pos.x)).max(start + min_dur)); - } - } - Some(DragKind::RubberBand { .. }) | None => {} - } - } - - if response.drag_stopped() { - match ix.drag.take() { - Some(DragKind::RubberBand { - anchor_frame, - anchor_row, - }) => { - if let Some(pos) = response.interact_pointer_pos() { - let f0 = anchor_frame.min(frame_at(pos.x)); - let f1 = anchor_frame.max(frame_at(pos.x)); - let r0 = anchor_row.min(row_at(pos.y)); - let r1 = anchor_row.max(row_at(pos.y)); - if !modifiers.shift { - selection.clear(); - } - for &lane in &ALL_LANES[r0..=r1] { - for c in cues.visible(lane, f0, f1) { - if c.end_frame() > f0 && c.start_frame < f1 { - selection.insert(c.id); - } - } - } - } - } - Some(_) => committed = true, - None => {} - } - } - - if response.clicked() - && let Some(pos) = response.interact_pointer_pos() - { - match hit_test(cues, pos).1 { - Hit::Body(id) | Hit::EdgeL(id) | Hit::EdgeR(id) => { - if modifiers.shift { - if !selection.remove(&id) { - selection.insert(id); - } - } else { - selection.clear(); - selection.insert(id); - } - } - Hit::Empty => selection.clear(), - } - } - - // Drop selection entries whose cues no longer exist. - selection.retain(|&id| cues.find(id).is_some()); - - // --- painting --- - - // Beat/downbeat ticks across the whole strip, density-adaptive. - if params.marks.is_usable() { - let visible = params - .marks - .visible_range(map.start_frame(), map.end_frame()); - let downbeats = visible - .clone() - .filter(|&i| params.marks.is_downbeat(i)) - .count(); - let plan = overlay_plan(rect.width(), visible.len(), downbeats); - let stride = plan.downbeat_stride as u32; - for i in visible { - let is_downbeat = params.marks.is_downbeat(i); - let stroke = if is_downbeat { - let bar = params.marks.bar_number(i); - if bar == 0 || !(bar - 1).is_multiple_of(stride) { - continue; - } - egui::Stroke::new(1.0_f32, palette::TICK_DOWNBEAT.gamma_multiply(0.35)) - } else { - if !plan.draw_beats { - continue; - } - egui::Stroke::new(1.0_f32, palette::TICK_BEAT.gamma_multiply(0.12)) - }; - let x = map.x(params.marks.frame(i)); - painter.line_segment( - [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], - stroke, - ); - } - } - - for (row, &(lane, label, color)) in LANES.iter().enumerate() { - let rr = row_rect(row); - for c in cues.visible(lane, map.start_frame(), map.end_frame()) { - let x0 = map.x(c.start_frame); - let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); - let bar = egui::Rect::from_min_max( - egui::pos2(x0, rr.top() + BAR_INSET_Y), - egui::pos2(x1, rr.bottom() - BAR_INSET_Y), - ); - painter.rect_filled( - bar, - 3.0, - color.gamma_multiply(0.45 + 0.45 * c.intensity.clamp(0.0, 1.0)), - ); - if selection.contains(&c.id) { - painter.rect_stroke( - bar, - 3.0, - egui::Stroke::new(1.5_f32, egui::Color32::WHITE), - egui::StrokeKind::Outside, - ); - } - } - painter.text( - egui::pos2(rr.left() + 6.0, rr.top() + 4.0), - egui::Align2::LEFT_TOP, - label, - egui::FontId::monospace(9.0), - color.gamma_multiply(0.6), - ); - } - - // Live rubber-band rectangle. - if let ( - Some(DragKind::RubberBand { - anchor_frame, - anchor_row, - }), - Some(pos), - ) = (&ix.drag, response.interact_pointer_pos()) - { - let x0 = map.x(*anchor_frame); - let r0 = row_rect(*anchor_row.min(&row_at(pos.y))); - let r1 = row_rect(*anchor_row.max(&row_at(pos.y))); - let band = egui::Rect::from_min_max( - egui::pos2(x0.min(pos.x), r0.top()), - egui::pos2(x0.max(pos.x), r1.bottom()), - ); - painter.rect_filled( - band, - 0.0, - egui::Color32::from_rgba_premultiplied(60, 90, 140, 40), - ); - painter.rect_stroke( - band, - 0.0, - egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(120, 160, 220)), - egui::StrokeKind::Inside, - ); - } - - // Centered playhead, continuing the zoomed view's. - let center_x = rect.center().x; - painter.line_segment( - [ - egui::pos2(center_x, rect.top()), - egui::pos2(center_x, rect.bottom()), - ], - egui::Stroke::new(2.0_f32, palette::PLAYHEAD), - ); - - committed -} diff --git a/crates/halo/src/waveform/mod.rs b/crates/halo/src/waveform/mod.rs index cad17e2..652bb33 100644 --- a/crates/halo/src/waveform/mod.rs +++ b/crates/halo/src/waveform/mod.rs @@ -4,17 +4,17 @@ //! live in the submodules. mod counter; -mod lanes; -mod lanes_editor; mod overview; mod peaks; +mod show_editor; +mod show_strip; mod zoomed; pub use counter::paint_beat_counter; -pub use lanes::{LanesParams, paint_lanes}; -pub use lanes_editor::{EditorInteraction, LanesEditorParams, lanes_editor, snap_frame}; pub use overview::{OverviewParams, OverviewTexture, paint_overview}; pub use peaks::BandPeaks; +pub use show_editor::{ShowEditorInteraction, ShowEditorParams, show_editor}; +pub use show_strip::{ShowStripParams, paint_show_strip}; pub use zoomed::{GhostPlayhead, ScrubGesture, ZoomSpan, ZoomedParams, paint_zoomed}; /// Label + color per lane, shared by the perform strip, the Prepare @@ -75,6 +75,10 @@ pub(crate) mod palette { pub const LANE_PIXELS: Color32 = Color32::from_rgb(240, 95, 175); /// FX (smoke/pyro) lane: green — amber is the accent, red the playhead. pub const LANE_FX: Color32 = Color32::from_rgb(70, 210, 130); + /// L3 energy envelope: amber, matching the loop/accent family. + pub const ENERGY: Color32 = Color32::from_rgb(235, 175, 50); + /// L3 accent one-shots: near-white so they read as hits, not a hue. + pub const ACCENT: Color32 = Color32::from_rgb(238, 238, 244); } /// Center-playhead frame→x mapping shared by the zoomed view and the @@ -114,6 +118,48 @@ impl FrameMap { } } +/// Nearest beat when snapping is on (and a grid exists); the raw frame +/// otherwise. Always non-negative. +pub fn snap_frame(marks: &GridMarks, snap: bool, frame: f64) -> f64 { + let frame = frame.max(0.0); + if !snap || !marks.is_usable() { + return frame; + } + match marks.beat_at_or_before(frame) { + Some(i) => { + let a = marks.frame(i); + let b = if i + 1 < marks.len() { + marks.frame(i + 1) + } else { + a + }; + if frame - a <= b - frame { a } else { b } + } + // Before the first beat: the first beat is the only grid point. + None => marks.frame(0).min(frame).max(0.0), + } +} + +/// Uneven lane rows for the L3 strip/editor: `heights` per row with 1 pt +/// separators between. Painters and hit-testing share this so the bands +/// never disagree. +pub(crate) fn lane_rows(rect: egui::Rect, heights: [f32; N]) -> [egui::Rect; N] { + let mut top = rect.top(); + heights.map(|h| { + let row = + egui::Rect::from_min_size(egui::pos2(rect.left(), top), egui::vec2(rect.width(), h)); + top += h + 1.0; + row + }) +} + +/// Which of `rows` contains `y` (clamped to the last row). +pub(crate) fn lane_row_at(rows: &[egui::Rect; N], y: f32) -> usize { + rows.iter() + .position(|r| y < r.bottom() + 0.5) + .unwrap_or(N - 1) +} + /// Beats in a bar for the counter/phrase math. The Stage 10 grid carries a /// 4/4 prior; bars with other beat counts wrap modulo 4 for display. const BEATS_PER_BAR: usize = 4; diff --git a/crates/halo/src/waveform/show_editor.rs b/crates/halo/src/waveform/show_editor.rs new file mode 100644 index 0000000..b10573b --- /dev/null +++ b/crates/halo/src/waveform/show_editor.rs @@ -0,0 +1,633 @@ +//! Direct-manipulation editor for the L3 show lanes: tall Look / Energy / +//! Accent rows sharing the zoomed view's frame→x mapping. Look events +//! drag as blocks (create-then-slide on empty space, armed palette look), +//! energy breakpoints drag in both axes, accents keep the legacy +//! draw/move/resize gestures. All positions are recomputed through the +//! [`FrameMap`] every frame, so editing stays correct while the timeline +//! plays underneath. +//! +//! Rubber-band selection is dropped for the preview (shift-click still +//! multi-selects); it can return with the full L3 landing. + +use std::collections::HashSet; + +use eframe::egui; + +use super::zoomed::ZoomSpan; +use super::{FrameMap, GridMarks, lane_row_at, lane_rows, overlay_plan, palette, snap_frame}; +use crate::show_preview::{ACCENT_LANE, LookId, ShowPreview, ShowSel}; + +/// Row heights, top to bottom: look / energy / accent. Energy is tallest +/// for y-drag resolution; accent keeps the legacy editor height. +const ROW_H: [f32; 3] = [36.0, 56.0, 44.0]; +const STRIP_HEIGHT: f32 = ROW_H[0] + ROW_H[1] + ROW_H[2] + 2.0; +/// Pointer distance to an accent edge that counts as a resize grab. +const EDGE_GRAB_PX: f32 = 5.0; +/// Pointer distance to an energy breakpoint that counts as a grab. +const POINT_GRAB_PX: f32 = 6.0; +/// Smallest musical accent duration, in beats (0.1 s without a grid). +const MIN_DUR_BEATS: f64 = 0.25; +/// Minimum separation between look events, in beats (0.5 s w/o a grid). +const LOOK_SEP_BEATS: f64 = 1.0; +const BAR_INSET_Y: f32 = 4.0; +const MIN_BAR_W: f32 = 2.0; +/// Intensity of a freshly drawn accent. +const CREATE_INTENSITY: f32 = 0.8; +/// Vertical inset of the energy envelope within its row. +const ENERGY_INSET: f32 = 5.0; +/// Energy breakpoint dot radius. +const POINT_R: f32 = 3.0; + +pub struct ShowEditorParams<'a> { + pub marks: &'a GridMarks, + pub position_frames: f64, + pub total_frames: usize, + pub sample_rate: u32, + /// Snap creates/moves/resizes to the nearest beat. + pub snap: bool, + /// Palette look a fresh look event is created with. + pub armed_look: LookId, +} + +/// Drag state carried between frames. +#[derive(Default)] +pub struct ShowEditorInteraction { + drag: Option, +} + +enum ShowDrag { + /// Slide a look event; `grab_offset` keeps the block under the + /// pointer instead of jumping its start to it. + LookMove { + id: u64, + grab_offset: f64, + }, + /// Drag a breakpoint in both axes. + EnergyMove { + id: u64, + }, + /// Draw a new accent between the anchor and the pointer. + AccentCreate { + id: u64, + anchor: f64, + }, + /// Move every selected accent by the pointer delta (original starts + /// are kept so per-frame clamping never accumulates). + AccentMove { + pointer_start: f64, + orig: Vec<(u64, f64)>, + }, + AccentResizeL { + id: u64, + }, + AccentResizeR { + id: u64, + }, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Row { + Look = 0, + Energy = 1, + Accent = 2, +} + +enum Hit { + LookBody(u64), + EnergyPoint(u64), + AccentEdgeL(u64), + AccentEdgeR(u64), + AccentBody(u64), + Empty(Row), +} + +/// Paints and edits the show lanes in place. Returns `true` when a +/// mutating gesture completed this frame — the caller syncs the show to +/// its sibling views then. +pub fn show_editor( + ui: &mut egui::Ui, + params: ShowEditorParams<'_>, + span: &ZoomSpan, + show: &mut ShowPreview, + selection: &mut HashSet, + ix: &mut ShowEditorInteraction, +) -> bool { + let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click_and_drag()); + let rect = response.rect; + let painter = painter.with_clip_rect(rect); + + painter.rect_filled(rect, 4.0, palette::LANE_BG); + + let rows = lane_rows(rect, ROW_H); + for row in &rows[1..] { + let y = row.top() - 0.5; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + + let row_label = |row: usize, color: egui::Color32, alpha: f32| { + let label = ["LOOK", "ENERGY", "ACCENT"][row]; + painter.text( + egui::pos2(rows[row].left() + 6.0, rows[row].top() + 4.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::monospace(9.0), + color.gamma_multiply(alpha), + ); + }; + let row_colors = [palette::TEXT_DIM, palette::ENERGY, palette::ACCENT]; + + let loaded = params.total_frames > 0; + if !loaded { + for (row, &color) in row_colors.iter().enumerate() { + row_label(row, color, 0.4); + } + return false; + } + + let span_frames = span.span_frames(params.marks, params.sample_rate); + let map = FrameMap::new(rect, params.position_frames, span_frames); + let total = params.total_frames as f64; + let frame_at = |x: f32| map.start_frame() + (x - rect.left()) as f64 / map.px_per_frame(); + let snap = |frame: f64| snap_frame(params.marks, params.snap, frame).min(total); + let beat = if params.marks.is_usable() && params.marks.median_beat_frames() > 0.0 { + params.marks.median_beat_frames() + } else { + 0.0 + }; + let min_dur = if beat > 0.0 { + MIN_DUR_BEATS * beat + } else { + 0.1 * params.sample_rate.max(1) as f64 + }; + let look_sep = if beat > 0.0 { + LOOK_SEP_BEATS * beat + } else { + 0.5 * params.sample_rate.max(1) as f64 + }; + + // Energy row value↔y mapping. + let energy_row = rows[Row::Energy as usize]; + let y_of = |v: f32| { + energy_row.bottom() + - ENERGY_INSET + - v.clamp(0.0, 1.0) * (energy_row.height() - 2.0 * ENERGY_INSET) + }; + let value_at_y = |y: f32| { + ((energy_row.bottom() - ENERGY_INSET - y) / (energy_row.height() - 2.0 * ENERGY_INSET)) + .clamp(0.0, 1.0) + }; + + // Block end of the look event at `idx` within the visible slice. + let look_block_end = |show: &ShowPreview, visible_idx: usize| -> f64 { + let visible = show.looks.visible(map.start_frame(), map.end_frame()); + visible.get(visible_idx + 1).map_or_else( + || { + show.looks + .events() + .iter() + .find(|e| e.frame >= map.end_frame()) + .map_or(total, |e| e.frame) + }, + |n| n.frame, + ) + }; + + let hit_test = |show: &ShowPreview, pos: egui::Pos2| -> Hit { + match lane_row_at(&rows, pos.y) { + 0 => { + let visible = show.looks.visible(map.start_frame(), map.end_frame()); + for (i, ev) in visible.iter().enumerate() { + let x0 = map.x(ev.frame); + let x1 = map.x(look_block_end(show, i)).max(x0 + MIN_BAR_W); + if pos.x >= x0 && pos.x < x1 { + return Hit::LookBody(ev.id); + } + } + Hit::Empty(Row::Look) + } + 1 => { + // Nearest breakpoint dot within grab range wins. + let mut best: Option<(f32, u64)> = None; + for p in show.energy.points() { + let dot = egui::pos2(map.x(p.frame), y_of(p.value)); + let d = dot.distance(pos); + if d <= POINT_GRAB_PX && best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, p.id)); + } + } + best.map_or(Hit::Empty(Row::Energy), |(_, id)| Hit::EnergyPoint(id)) + } + _ => { + // Edges win over bodies; later (topmost-drawn) cues win. + let mut hit = Hit::Empty(Row::Accent); + for c in show + .accents + .visible(ACCENT_LANE, map.start_frame(), map.end_frame()) + { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + if (pos.x - x0).abs() <= EDGE_GRAB_PX { + hit = Hit::AccentEdgeL(c.id); + } else if (pos.x - x1).abs() <= EDGE_GRAB_PX { + hit = Hit::AccentEdgeR(c.id); + } else if pos.x > x0 && pos.x < x1 { + hit = Hit::AccentBody(c.id); + } + } + hit + } + } + }; + + // Hover cursor feedback (only while not mid-drag). + if ix.drag.is_none() + && let Some(pos) = response.hover_pos() + { + match hit_test(show, pos) { + Hit::AccentEdgeL(_) | Hit::AccentEdgeR(_) => { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + Hit::LookBody(_) | Hit::EnergyPoint(_) | Hit::AccentBody(_) => { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + Hit::Empty(_) => {} + } + } + + let mut committed = false; + let modifiers = ui.input(|i| i.modifiers); + + if response.drag_started() + && let Some(pos) = response.interact_pointer_pos() + { + ix.drag = match hit_test(show, pos) { + Hit::LookBody(id) => { + if !selection.contains(&ShowSel::Look(id)) { + selection.clear(); + selection.insert(ShowSel::Look(id)); + } + show.looks.find(id).map(|ev| ShowDrag::LookMove { + id, + grab_offset: frame_at(pos.x) - ev.frame, + }) + } + Hit::EnergyPoint(id) => { + selection.clear(); + selection.insert(ShowSel::Energy(id)); + Some(ShowDrag::EnergyMove { id }) + } + Hit::AccentBody(id) => { + if !selection.contains(&ShowSel::Accent(id)) { + selection.clear(); + selection.insert(ShowSel::Accent(id)); + } + let mut orig: Vec<(u64, f64)> = selection + .iter() + .filter_map(|&sel| match sel { + ShowSel::Accent(id) => { + show.accents.find(id).map(|(_, c)| (id, c.start_frame)) + } + _ => None, + }) + .collect(); + orig.sort_by(|a, b| a.1.total_cmp(&b.1)); + Some(ShowDrag::AccentMove { + pointer_start: frame_at(pos.x), + orig, + }) + } + Hit::AccentEdgeL(id) => { + selection.clear(); + selection.insert(ShowSel::Accent(id)); + Some(ShowDrag::AccentResizeL { id }) + } + Hit::AccentEdgeR(id) => { + selection.clear(); + selection.insert(ShowSel::Accent(id)); + Some(ShowDrag::AccentResizeR { id }) + } + Hit::Empty(Row::Look) => { + // Create with the armed look, then slide. + let anchor = snap(frame_at(pos.x)); + show.looks + .insert(anchor, params.armed_look, look_sep) + .map(|id| { + selection.clear(); + selection.insert(ShowSel::Look(id)); + ShowDrag::LookMove { + id, + grab_offset: 0.0, + } + }) + } + Hit::Empty(Row::Energy) => { + // Add a breakpoint under the pointer, then slide. + let id = show.energy.insert(snap(frame_at(pos.x)), value_at_y(pos.y)); + selection.clear(); + selection.insert(ShowSel::Energy(id)); + Some(ShowDrag::EnergyMove { id }) + } + Hit::Empty(Row::Accent) => { + let anchor = snap(frame_at(pos.x)); + show.accents + .insert(ACCENT_LANE, anchor, min_dur, CREATE_INTENSITY) + .map(|id| { + selection.clear(); + selection.insert(ShowSel::Accent(id)); + ShowDrag::AccentCreate { id, anchor } + }) + } + }; + } + + if response.dragged() + && let Some(pos) = response.interact_pointer_pos() + { + match &ix.drag { + Some(ShowDrag::LookMove { id, grab_offset }) => { + show.looks + .move_event(*id, snap(frame_at(pos.x) - grab_offset), look_sep); + } + Some(ShowDrag::EnergyMove { id }) => { + show.energy + .move_point(*id, snap(frame_at(pos.x)), value_at_y(pos.y)); + } + Some(ShowDrag::AccentCreate { id, anchor }) => { + let p = snap(frame_at(pos.x)); + let (lo, hi) = if p < *anchor { + (p, *anchor) + } else { + (*anchor, p) + }; + show.accents.resize(*id, lo, hi.max(lo + min_dur)); + } + Some(ShowDrag::AccentMove { + pointer_start, + orig, + }) => { + if let Some(&(_, first_start)) = orig.first() { + let raw_delta = frame_at(pos.x) - pointer_start; + let delta = snap(first_start + raw_delta) - first_start; + // Order matters so grouped accents don't clamp against + // a not-yet-moved neighbor: lead with the travel edge. + if delta >= 0.0 { + for &(id, start) in orig.iter().rev() { + show.accents.move_cue(id, start + delta); + } + } else { + for &(id, start) in orig.iter() { + show.accents.move_cue(id, start + delta); + } + } + } + } + Some(ShowDrag::AccentResizeL { id }) => { + if let Some((_, c)) = show.accents.find(*id) { + let end = c.end_frame(); + show.accents + .resize(*id, snap(frame_at(pos.x)).min(end - min_dur), end); + } + } + Some(ShowDrag::AccentResizeR { id }) => { + if let Some((_, c)) = show.accents.find(*id) { + let start = c.start_frame; + show.accents + .resize(*id, start, snap(frame_at(pos.x)).max(start + min_dur)); + } + } + None => {} + } + } + + if response.drag_stopped() && ix.drag.take().is_some() { + committed = true; + } + + if response.clicked() + && let Some(pos) = response.interact_pointer_pos() + { + let sel = match hit_test(show, pos) { + Hit::LookBody(id) => Some(ShowSel::Look(id)), + Hit::EnergyPoint(id) => Some(ShowSel::Energy(id)), + Hit::AccentBody(id) | Hit::AccentEdgeL(id) | Hit::AccentEdgeR(id) => { + Some(ShowSel::Accent(id)) + } + Hit::Empty(_) => None, + }; + match sel { + Some(sel) => { + if modifiers.shift { + if !selection.remove(&sel) { + selection.insert(sel); + } + } else { + selection.clear(); + selection.insert(sel); + } + } + None => selection.clear(), + } + } + + // Secondary click deletes an energy breakpoint outright. + if response.secondary_clicked() + && let Some(pos) = response.interact_pointer_pos() + && let Hit::EnergyPoint(id) = hit_test(show, pos) + { + show.energy.remove(id); + selection.remove(&ShowSel::Energy(id)); + committed = true; + } + + // Drop selection entries whose items no longer exist. + selection.retain(|&sel| match sel { + ShowSel::Look(id) => show.looks.find(id).is_some(), + ShowSel::Energy(id) => show.energy.find(id).is_some(), + ShowSel::Accent(id) => show.accents.find(id).is_some(), + }); + + // --- painting --- + + // Beat/downbeat ticks across the whole strip, density-adaptive. + if params.marks.is_usable() { + let visible = params + .marks + .visible_range(map.start_frame(), map.end_frame()); + let downbeats = visible + .clone() + .filter(|&i| params.marks.is_downbeat(i)) + .count(); + let plan = overlay_plan(rect.width(), visible.len(), downbeats); + let stride = plan.downbeat_stride as u32; + for i in visible { + let is_downbeat = params.marks.is_downbeat(i); + let stroke = if is_downbeat { + let bar = params.marks.bar_number(i); + if bar == 0 || !(bar - 1).is_multiple_of(stride) { + continue; + } + egui::Stroke::new(1.0_f32, palette::TICK_DOWNBEAT.gamma_multiply(0.35)) + } else { + if !plan.draw_beats { + continue; + } + egui::Stroke::new(1.0_f32, palette::TICK_BEAT.gamma_multiply(0.12)) + }; + let x = map.x(params.marks.frame(i)); + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + stroke, + ); + } + } + + // Look blocks. + let rr = rows[Row::Look as usize]; + let visible: Vec<_> = show + .looks + .visible(map.start_frame(), map.end_frame()) + .to_vec(); + for (i, ev) in visible.iter().enumerate() { + let color = ev.look.def().color; + let x0 = map.x(ev.frame); + let x1 = map.x(look_block_end(show, i)).max(x0 + MIN_BAR_W); + let block = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + BAR_INSET_Y), + egui::pos2(x1, rr.bottom() - BAR_INSET_Y), + ); + painter.rect_filled(block, 3.0, color.gamma_multiply(0.30)); + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x0, block.top()), + egui::pos2(x0 + 2.5, block.bottom()), + ), + 0.0, + color.gamma_multiply(0.95), + ); + // Pin carried-in block names to the visible edge, clear of the + // row label. + let label_x = (x0 + 7.0).max(rect.left() + 52.0); + painter.text( + egui::pos2(label_x, block.center().y), + egui::Align2::LEFT_CENTER, + ev.look.def().name, + egui::FontId::monospace(9.0), + egui::Color32::WHITE.gamma_multiply(0.8), + ); + if selection.contains(&ShowSel::Look(ev.id)) { + painter.rect_stroke( + block, + 3.0, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + egui::StrokeKind::Outside, + ); + } + } + + // Energy envelope: fill, line, then draggable dots. + let energy = &show.energy; + if energy.points().is_empty() { + painter.line_segment( + [ + egui::pos2(rect.left(), y_of(1.0)), + egui::pos2(rect.right(), y_of(1.0)), + ], + egui::Stroke::new(1.0_f32, palette::ENERGY.gamma_multiply(0.3)), + ); + } else { + let mut xs: Vec<(f32, f32)> = vec![(rect.left(), energy.value_at(map.start_frame()))]; + for p in energy.points() { + if p.frame > map.start_frame() && p.frame < map.end_frame() { + xs.push((map.x(p.frame), p.value)); + } + } + xs.push((rect.right(), energy.value_at(map.end_frame()))); + + // One convex trapezoid per segment — epaint's filled paths assume + // convexity, which a multi-breakpoint envelope doesn't satisfy. + let fill = palette::ENERGY.gamma_multiply(0.12); + let mut mesh = egui::Mesh::default(); + for w in xs.windows(2) { + let (x0, v0) = w[0]; + let (x1, v1) = w[1]; + let i = mesh.vertices.len() as u32; + for (x, y) in [ + (x0, y_of(v0)), + (x1, y_of(v1)), + (x1, energy_row.bottom() - ENERGY_INSET), + (x0, energy_row.bottom() - ENERGY_INSET), + ] { + mesh.colored_vertex(egui::pos2(x, y), fill); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + } + painter.add(mesh); + let line: Vec = xs.iter().map(|&(x, v)| egui::pos2(x, y_of(v))).collect(); + painter.add(egui::Shape::line( + line, + egui::Stroke::new(1.5_f32, palette::ENERGY.gamma_multiply(0.9)), + )); + for p in energy.points() { + let dot = egui::pos2(map.x(p.frame), y_of(p.value)); + if dot.x < rect.left() || dot.x > rect.right() { + continue; + } + painter.circle_filled(dot, POINT_R, palette::ENERGY); + if selection.contains(&ShowSel::Energy(p.id)) { + painter.circle_stroke( + dot, + POINT_R + 1.5, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + ); + } + } + } + + // Accent bars. + let rr = rows[Row::Accent as usize]; + for c in show + .accents + .visible(ACCENT_LANE, map.start_frame(), map.end_frame()) + { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + let bar = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + BAR_INSET_Y), + egui::pos2(x1, rr.bottom() - BAR_INSET_Y), + ); + painter.rect_filled( + bar, + 3.0, + palette::ACCENT.gamma_multiply(0.45 + 0.45 * c.intensity.clamp(0.0, 1.0)), + ); + if selection.contains(&ShowSel::Accent(c.id)) { + painter.rect_stroke( + bar, + 3.0, + egui::Stroke::new(1.5_f32, egui::Color32::WHITE), + egui::StrokeKind::Outside, + ); + } + } + + for (row, &color) in row_colors.iter().enumerate() { + row_label(row, color, 0.6); + } + + // Centered playhead, continuing the zoomed view's. + let center_x = rect.center().x; + painter.line_segment( + [ + egui::pos2(center_x, rect.top()), + egui::pos2(center_x, rect.bottom()), + ], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD), + ); + + committed +} diff --git a/crates/halo/src/waveform/show_strip.rs b/crates/halo/src/waveform/show_strip.rs new file mode 100644 index 0000000..833e09f --- /dev/null +++ b/crates/halo/src/waveform/show_strip.rs @@ -0,0 +1,274 @@ +//! L3 show strip: the three role lanes (Look / Energy / Accent) under +//! the zoomed waveform, sharing its frame→x mapping and centered playhead +//! so everything scrolls in lockstep. Read-only — editing lives in the +//! Prepare view's `show_editor`. + +use eframe::egui; +use halo_light::cues::LANE_COUNT; + +use super::zoomed::ZoomSpan; +use super::{FrameMap, GridMarks, lane_rows, palette}; +use crate::show_preview::{ACCENT_LANE, ShowPreview}; + +/// Row heights, top to bottom: look / energy / accent. +const ROW_H: [f32; LANE_COUNT] = [18.0, 22.0, 12.0]; +/// Full strip height: three rows plus two 1 pt separators. +const STRIP_HEIGHT: f32 = ROW_H[0] + ROW_H[1] + ROW_H[2] + 2.0; +/// Vertical inset of an accent bar within its row. +const BAR_INSET_Y: f32 = 2.0; +/// Bars/blocks never collapse below this width at wide zooms. +const MIN_BAR_W: f32 = 2.0; +/// Extra dim applied to every lane when the deck isn't driving lighting. +const INACTIVE_DIM: f32 = 0.30; +/// Width of the left label gutter in points. +const LABEL_GUTTER_W: f32 = 30.0; +/// Look blocks narrower than this skip their name label. +const MIN_LABEL_W: f32 = 28.0; + +pub struct ShowStripParams<'a> { + pub show: &'a ShowPreview, + pub marks: &'a GridMarks, + pub position_frames: f64, + pub total_frames: usize, + pub sample_rate: u32, + /// This deck currently drives the lighting rig; dim everything when + /// false. + pub lighting_active: bool, + /// The live programmer is replacing rig output. In the L3 stack + /// (programmer replace > energy scale > look) that displaces the + /// look, so the look row tints and its blocks render hollow. + pub programmer_override: bool, +} + +/// Paint the three role lanes. +pub fn paint_show_strip(ui: &mut egui::Ui, params: ShowStripParams<'_>, span: &ZoomSpan) { + let desired_size = egui::vec2(ui.available_width(), STRIP_HEIGHT); + let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::hover()); + let rect = response.rect; + let painter = painter.with_clip_rect(rect); + + painter.rect_filled(rect, 4.0, palette::LANE_BG); + + let dim = |color: egui::Color32, alpha: f32| { + let alpha = if params.lighting_active { + alpha + } else { + alpha * INACTIVE_DIM + }; + color.gamma_multiply(alpha) + }; + + let rows = lane_rows(rect, ROW_H); + for row in &rows[1..] { + let y = row.top() - 0.5; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + + let loaded = params.total_frames > 0; + let mut active_look_color = palette::TEXT_DIM; + + if loaded { + let span_frames = span.span_frames(params.marks, params.sample_rate); + let map = FrameMap::new(rect, params.position_frames, span_frames); + let total = params.total_frames as f64; + + // --- Look row: color-script blocks holding until the next event. + let rr = rows[0]; + if params.programmer_override { + painter.rect_filled(rr, 0.0, egui::Color32::WHITE.gamma_multiply(0.06)); + } + let looks = ¶ms.show.looks; + let visible = looks.visible(map.start_frame(), map.end_frame()); + let active = looks.active_at(params.position_frames).map(|e| e.id); + // The last visible block runs to the first event past the window + // (which `visible` excludes), or to the end of the track. + let end_past_window = looks + .events() + .iter() + .find(|e| e.frame >= map.end_frame()) + .map_or(total, |e| e.frame); + for (i, ev) in visible.iter().enumerate() { + let color = ev.look.def().color; + let x0 = map.x(ev.frame); + let block_end = visible.get(i + 1).map_or(end_past_window, |n| n.frame); + let x1 = map.x(block_end).max(x0 + MIN_BAR_W); + let block = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + 1.0), + egui::pos2(x1, rr.bottom() - 1.0), + ); + let is_active = active == Some(ev.id) && !params.programmer_override; + let fill = if is_active { 0.45 } else { 0.25 }; + if params.programmer_override { + painter.rect_stroke( + block, + 2.0, + egui::Stroke::new(1.0_f32, dim(color, 0.7)), + egui::StrokeKind::Inside, + ); + } else { + painter.rect_filled(block, 2.0, dim(color, fill)); + } + if is_active { + painter.rect_stroke( + block, + 2.0, + egui::Stroke::new(1.0_f32, dim(color, 0.9)), + egui::StrokeKind::Inside, + ); + active_look_color = color; + } + // Solid left-edge tick: the event marker itself. + painter.rect_filled( + egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + 1.0), + egui::pos2(x0 + 2.0, rr.bottom() - 1.0), + ), + 0.0, + dim(color, 0.9), + ); + if x1 - x0 >= MIN_LABEL_W { + // Carried-in blocks keep their name readable: the label + // pins to the visible edge (past the gutter) instead of + // sitting at an off-screen block start. + let label_x = (x0 + 5.0).max(rect.left() + LABEL_GUTTER_W + 5.0); + painter.text( + egui::pos2(label_x, rr.center().y), + egui::Align2::LEFT_CENTER, + ev.look.def().name, + egui::FontId::monospace(8.0), + dim(egui::Color32::WHITE, 0.75), + ); + } + } + + // --- Energy row: the envelope polyline with an under-curve fill. + let rr = rows[1]; + let inset = 2.0; + let y_of = |v: f32| rr.bottom() - inset - v.clamp(0.0, 1.0) * (rr.height() - 2.0 * inset); + let energy = ¶ms.show.energy; + if energy.points().is_empty() { + painter.line_segment( + [ + egui::pos2(rect.left(), y_of(1.0)), + egui::pos2(rect.right(), y_of(1.0)), + ], + egui::Stroke::new(1.0_f32, dim(palette::ENERGY, 0.25)), + ); + } else { + // Sample points: window edges plus every breakpoint inside. + let mut xs: Vec<(f32, f32)> = vec![(rect.left(), energy.value_at(map.start_frame()))]; + for p in energy.points() { + if p.frame > map.start_frame() && p.frame < map.end_frame() { + xs.push((map.x(p.frame), p.value)); + } + } + xs.push((rect.right(), energy.value_at(map.end_frame()))); + + // Under-curve fill as one convex trapezoid per segment — + // epaint's filled paths assume convexity, which a + // multi-breakpoint envelope doesn't satisfy. + let fill = dim(palette::ENERGY, 0.15); + let mut mesh = egui::Mesh::default(); + for w in xs.windows(2) { + let (x0, v0) = w[0]; + let (x1, v1) = w[1]; + let i = mesh.vertices.len() as u32; + for (x, y) in [ + (x0, y_of(v0)), + (x1, y_of(v1)), + (x1, rr.bottom() - inset), + (x0, rr.bottom() - inset), + ] { + mesh.colored_vertex(egui::pos2(x, y), fill); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + } + painter.add(mesh); + let line: Vec = xs.iter().map(|&(x, v)| egui::pos2(x, y_of(v))).collect(); + painter.add(egui::Shape::line( + line, + egui::Stroke::new(1.5_f32, dim(palette::ENERGY, 0.9)), + )); + } + + // --- Accent row: one-shot bars, legacy rendering. + let rr = rows[2]; + for c in params + .show + .accents + .visible(ACCENT_LANE, map.start_frame(), map.end_frame()) + { + let x0 = map.x(c.start_frame); + let x1 = map.x(c.end_frame()).max(x0 + MIN_BAR_W); + let bar = egui::Rect::from_min_max( + egui::pos2(x0, rr.top() + BAR_INSET_Y), + egui::pos2(x1, rr.bottom() - BAR_INSET_Y), + ); + let alpha = 0.55 + 0.45 * c.intensity.clamp(0.0, 1.0); + painter.rect_filled(bar, 2.0, dim(palette::ACCENT, alpha)); + } + } + + // Left label gutter, painted over the content so the frame→x mapping + // stays full-width and pixel-identical to the zoomed view above. + let gutter = egui::Rect::from_min_max( + rect.left_top(), + egui::pos2(rect.left() + LABEL_GUTTER_W, rect.bottom()), + ); + painter.rect_filled( + gutter, + egui::CornerRadius { + nw: 4, + ne: 0, + sw: 4, + se: 0, + }, + palette::LANE_BG, + ); + for row in &rows[1..] { + let y = row.top() - 0.5; + painter.line_segment( + [egui::pos2(gutter.left(), y), egui::pos2(gutter.right(), y)], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + } + painter.line_segment( + [ + egui::pos2(gutter.right() + 0.5, rect.top()), + egui::pos2(gutter.right() + 0.5, rect.bottom()), + ], + egui::Stroke::new(1.0_f32, palette::LANE_SEPARATOR), + ); + for (row, (label, color)) in [ + ("LOOK", active_look_color), + ("NRG", palette::ENERGY), + ("ACC", palette::ACCENT), + ] + .into_iter() + .enumerate() + { + painter.text( + egui::pos2(gutter.center().x, rows[row].center().y), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::monospace(8.0), + dim(color, 0.9), + ); + } + + // Continue the zoomed view's centered playhead through the strip. + if loaded { + let center_x = rect.center().x; + painter.line_segment( + [ + egui::pos2(center_x, rect.top()), + egui::pos2(center_x, rect.bottom()), + ], + egui::Stroke::new(2.0_f32, palette::PLAYHEAD), + ); + } +}