From fb672fa05f725428063ff4ba510c0b5ad70396a1 Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:18:03 +0530 Subject: [PATCH 01/10] MIDI Editor: holding a piano key or a note plays it on the track's instrument (#373) The player takes note-on/off for a track's instrument outside the clips, and renders while a note is held or releasing, so audition works with the transport stopped. Stopped, the mix engine now runs instruments without their clips and no longer treats the still position as a seek. Co-Authored-By: Claude Opus 5.5 --- crates/mix/src/lib.rs | 43 ++++++++++++++++++++++++++++--- crates/playback/src/lib.rs | 34 +++++++++++++++++++++++- crates/ui-egui/src/midi_editor.rs | 39 ++++++++++++++++++++++++++-- 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a8c8b5695..79fbf6fdb 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -474,6 +474,13 @@ impl MixEngine { self.main.len() } + /// Play (velocity > 0) or release (velocity 0) `pitch` on a track's instrument at the next + /// block, outside the clips: the MIDI Editor's audition. Does nothing before `sync`. + pub fn live_note(&mut self, track: TrackId, pitch: u8, velocity: u8) { + let Some(inst) = self.strips.get_mut(&track).and_then(|s| s.instrument.as_mut()) else { return }; + if velocity > 0 { inst.plugin.note_on(0, pitch.min(127), velocity.min(127)) } else { inst.plugin.note_off(0, pitch.min(127)) } + } + /// Reset all plugin state (on stop / seek). pub fn reset(&mut self) { for s in self.strips.values_mut() { @@ -585,7 +592,8 @@ impl MixEngine { /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels). pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec]) { let frames = frames.min(self.max_block); - if pos != self.last_end { + // Stopped, the position stands still: not a seek (it would cut auditioned notes). + if pos != self.last_end && !self.monitor_only { // Seek: silence held notes and ringing plugins. for st in self.strips.values_mut() { if let Some(i) = &mut st.instrument { @@ -853,7 +861,6 @@ fn process_strip( } } TrackKind::Audio if live.monitor_only => {} - TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {} TrackKind::Audio => { let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; for clip in t.clips() { @@ -914,7 +921,10 @@ fn process_strip( if let Some(ins) = &t.instrument { apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } - schedule_notes(s, t, pos, frames, inst, &mut strip.held); + // Stopped: no clips, but the instrument still plays auditioned notes. + if !live.monitor_only { + schedule_notes(s, t, pos, frames, inst, &mut strip.held); + } if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); } else if let Some(c0) = strip.buf.first_mut() { @@ -2092,6 +2102,33 @@ mod tests { assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}"); } + #[test] + fn live_notes_sound_while_stopped_without_the_clips() { + let s = synth_session(-6.0); + let t = s.tracks[0].id; + let mut eng = MixEngine::new(48_000.0, 512); + eng.monitor_only = true; + let mut out = vec![vec![0.0f32; 512]; 2]; + let peak = |out: &[Vec]| out.iter().flatten().fold(0.0f32, |m, x| m.max(x.abs())); + let mut loudest = 0.0f32; + for _ in 0..8 { + eng.render(&s, 0, 512, &mut out); + loudest = loudest.max(peak(&out)); + } + assert_eq!(loudest, 0.0, "stopped: the clip's note must not play"); + eng.live_note(t, 72, 100); + for _ in 0..8 { + eng.render(&s, 0, 512, &mut out); + loudest = loudest.max(peak(&out)); + } + assert!(loudest > 0.01, "the auditioned note should sound: {loudest}"); + eng.live_note(t, 72, 0); + for _ in 0..400 { + eng.render(&s, 0, 512, &mut out); + } + assert!(peak(&out) < 1e-4, "the released note should die away: {}", peak(&out)); + } + #[test] fn insert_slot_a_automation_does_not_drive_the_instrument() { let mut s = synth_session(-6.0); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 5ac740984..9fbf68c80 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -72,6 +72,12 @@ enum Cmd { }, Stop, Locate(Samples), + /// Audition a note on a track's instrument (velocity 0 releases it). + Note { + track: TrackId, + pitch: u8, + velocity: u8, + }, } struct Shared { @@ -101,6 +107,9 @@ struct AudioState { device_rate: f64, input: Option>, recording: bool, + /// Auditioned notes held, and the frames left to render the last one's release. + live_notes: usize, + live_tail: usize, /// Retired plugin instances go back to the UI thread to be dropped there. garbage: Sender>>, old_sessions: Option>>, @@ -135,6 +144,8 @@ impl AudioState { device_rate, input: None, recording: false, + live_notes: 0, + live_tail: 0, } } @@ -184,6 +195,16 @@ impl AudioState { self.looped = None; self.mix.reset(); } + Cmd::Note { track, pitch, velocity } => { + self.mix.live_note(track, pitch, velocity); + if velocity > 0 { + self.live_notes = self.live_notes.saturating_add(1); + } else { + self.live_notes = self.live_notes.saturating_sub(1); + // Long enough for a synth's release to ring out. + self.live_tail = (self.device_rate * 2.0) as usize; + } + } Cmd::Locate(at) => { self.pos = at; self.pending_len = 0; @@ -268,7 +289,9 @@ impl AudioState { fn fill(&mut self, out: &mut [f32], channels: usize) { self.drain(); let channels = channels.max(1); - let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm); + let monitoring = self.live_notes > 0 + || self.live_tail > 0 + || self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm); if !self.playing && !monitoring { out.iter_mut().for_each(|x| *x = 0.0); if let Ok(mut m) = self.shared.meters.try_lock() { @@ -280,6 +303,9 @@ impl AudioState { let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); let callback_frames = out.len() / channels; + if self.live_notes == 0 { + self.live_tail = self.live_tail.saturating_sub(callback_frames); + } let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK); for frame in out.chunks_mut(channels) { while self.read >= self.pending_len as f64 { @@ -641,6 +667,12 @@ impl Player { let _ = self.tx.send(Cmd::Recording(on)); } + /// Play `pitch` on `track`'s instrument while the transport is stopped or playing, until a + /// call with velocity 0 releases it (the MIDI Editor's audition). + pub fn note(&self, track: TrackId, pitch: u8, velocity: u8) { + let _ = self.tx.send(Cmd::Note { track, pitch, velocity }); + } + pub fn stop(&self) { self.shared.playing.store(false, Ordering::Relaxed); let _ = self.tx.send(Cmd::Stop); diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index a269e9483..bf751e90f 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -4,7 +4,7 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use soundcraft_model::{ClipContent, ClipId}; +use soundcraft_model::{ClipContent, ClipId, TrackId}; use soundcraft_time::TICKS_PER_QUARTER; const KEY_W: f32 = 48.0; @@ -19,6 +19,8 @@ pub struct MidiEditorState { pub clip: Option, /// Rubber-band selection start (screen position). pub band: Option, + /// The note sounding on the track's instrument while a key or note is held. + pub audition: Option<(TrackId, u8)>, } #[derive(Debug, Clone)] @@ -55,7 +57,26 @@ fn is_black(p: i32) -> bool { matches!(p.rem_euclid(12), 1 | 3 | 6 | 8 | 10) } +/// Sound `want` on the instrument, releasing the note that was sounding. +fn audition(app: &mut SoundApp, want: Option<(TrackId, u8, u8)>) { + if app.midi.audition == want.map(|(t, p, _)| (t, p)) { + return; + } + if let Some(player) = &app.player { + if let Some((t, p)) = app.midi.audition { + player.note(t, p, 0); + } + if let Some((t, p, v)) = want { + player.note(t, p, v); + } + } + app.midi.audition = want.map(|(t, p, _)| (t, p)); +} + pub fn show(app: &mut SoundApp, ui: &mut Ui) { + if !ui.input(|i| i.pointer.primary_down()) { + audition(app, None); + } // Painter-only contents do not advance the layout. Claim the resized // space, including the empty state, so the dock keeps its chosen height. ui.take_available_space(); @@ -215,6 +236,21 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { }) }; let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; + // Audition: a held piano key plays its pitch (sliding across keys plays each one); a held + // note plays at its velocity, following the pitch it is dragged to. + let key_resp = ui.interact(keys, ui.id().with(("keys", cid.0)), Sense::click_and_drag()); + let pointer = ui.ctx().pointer_latest_pos(); + let want = if key_resp.is_pointer_button_down_on() { + pointer.filter(|p| keys.contains(*p)).map(|p| (pitch_at(p.y).clamp(0, 127) as u8, 100)) + } else if resp.is_pointer_button_down_on() { + ui.input(|i| i.pointer.press_origin()).and_then(hit).and_then(|(i, _)| sequence.notes.get(i)).map(|n| { + let dy = app.midi.drag.as_ref().filter(|d| !d.resize).map_or(0, |d| d.dy_semi); + ((i64::from(n.pitch) + dy).clamp(0, 127) as u8, n.velocity.max(1)) + }) + } else { + None + }; + audition(app, want.map(|(p, v)| (track, p, v))); if resp.drag_started() && let Some(p) = resp.interact_pointer_pos() { @@ -284,7 +320,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } else { app.midi.selected = vec![i]; } - // Audition on the instrument would go here (needs a live MIDI input path). } None => { let pitch = pitch_at(p.y).clamp(0, 127); From 17744e8cb01e0a9beff581b42a04b58e46c6a798 Mon Sep 17 00:00:00 2001 From: Justin John <34035011+justinjohn0306@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:18:04 +0530 Subject: [PATCH 02/10] Credit justinjohn0306 by GitHub profile name Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index befac751a..55321a2a0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -47,3 +47,6 @@ display_name = "Jason Brashear" [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.justinjohn0306] +sync_github_name = true From 6160c7162d8a1c2cf4811452ff877f95b260cebb Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Tue, 13 Oct 2026 20:38:25 +0200 Subject: [PATCH 03/10] MIDI Editor: zoom, ruler and playhead; Pencil drags, group trims, Alt-drag copies - View: Ctrl/Cmd+wheel zooms time around the pointer, Alt+wheel the row height, Shift+wheel scrolls sideways. A bars ruler with beat and grid lines on the session timeline; clicking it locates. The playhead pages along while playing; the insertion point shows when stopped. Past the clip end is shaded. - Notes: the Pencil draws a note as long as the drag (and plays it while held). Trimming either edge of a selected note trims every selected note. Alt-drag copies the selection. Moved, copied and trimmed notes stay selected. - Snap follows the Edit mode (Grid, Relative Grid); Ctrl/Cmd held while dragging suspends or enables it for that drag. Grid lines count from the session start, so notes land on the timeline's beats wherever the clip starts. - Velocity lane: with notes selected only theirs change (one note of a chord); a stroke sets every stem it crosses, in one undo step. - Note name, velocity, position and length next to the pointer; colours from the theme. - Engine: midi.notes_move takes `copy`; new midi.notes_trim moves note edges of several notes in one step; both return the notes' new indices. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/midi.rs | 114 ++- crates/ui-egui/src/midi_editor.rs | 732 +++++++++++++++---- crates/ui-egui/tests/midi_editor_gestures.rs | 177 +++++ 3 files changed, 873 insertions(+), 150 deletions(-) create mode 100644 crates/ui-egui/tests/midi_editor_gestures.rs diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 05f6b6acd..d34100bb0 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -22,7 +22,16 @@ pub fn specs() -> Vec { cmd!("midi.note_add", "Add Note", [], None, "{clip, pitch, start_ticks, length_ticks?: 480, velocity?: 100, channel?: 0}", always, note_add), cmd!("midi.note_edit", "Edit Note", [], None, "{clip, index, pitch?, start_ticks?, length_ticks?, velocity?}", always, note_edit), cmd!("midi.note_delete", "Delete Notes", [], None, "{clip, indices: [n]}", always, note_delete), - cmd!("midi.notes_move", "Move Notes", [], None, "{clip, indices: [n], ticks?: 0, semitones?: 0}", always, notes_move), + cmd!("midi.notes_move", "Move Notes", [], None, "{clip, indices: [n], ticks?: 0, semitones?: 0, copy?: false}", always, notes_move), + cmd!( + "midi.notes_trim", + "Trim Notes", + [], + None, + "{clip, indices: [n], start?: 0, end?: 0} (tick offsets for the note edges)", + always, + notes_trim + ), ] } @@ -123,26 +132,63 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result { let idx = indices(p); let dt = i64_or(p, "ticks", 0); let ds = i64_or(p, "semitones", 0); + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); let seq = seq_mut(e, id, "midi.notes_move")?; + let mut moved = Vec::with_capacity(idx.len()); for (i, n) in seq.notes.iter_mut().enumerate() { if idx.contains(&i) { - n.start = (n.start + dt).max(0); - n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8; + let mut m = *n; + m.start = (m.start + dt).max(0); + m.pitch = (i64::from(m.pitch) + ds).clamp(0, 127) as u8; + if !copy { + *n = m; + } + moved.push(m); } } - let moved: Vec = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect(); + if copy { + seq.notes.extend_from_slice(&moved); + } seq.sort(); - // Where the moved notes ended up after sorting, so the editor can keep them selected. + // Where the moved notes (or the copies) ended up after sorting, so the editor can keep them selected. + Ok(json!({"moved": moved.len(), "indices": relocate(seq, &moved)})) +} + +/// Trim the start and/or end of several notes at once: `start` and `end` are tick offsets for the +/// note edges (a start trim keeps the note's end in place). Notes never shrink below one tick. +fn notes_trim(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.notes_trim")?; + let idx = indices(p); + let ds = i64_or(p, "start", 0); + let de = i64_or(p, "end", 0); + let seq = seq_mut(e, id, "midi.notes_trim")?; + let mut trimmed = Vec::with_capacity(idx.len()); + for (i, n) in seq.notes.iter_mut().enumerate() { + if idx.contains(&i) { + let end = n.end().saturating_add(de).max(1); + n.start = n.start.saturating_add(ds).clamp(0, end - 1); + n.length = (end - n.start).max(1); + trimmed.push(*n); + } + } + seq.sort(); + Ok(json!({"trimmed": trimmed.len(), "indices": relocate(seq, &trimmed)})) +} + +/// The sorted indices `notes` now sit at in `seq` (each matched once, so duplicates stay distinct). +fn relocate(seq: &Sequence, notes: &[Note]) -> Vec { let mut taken = vec![false; seq.notes.len()]; - let mut indices = Vec::with_capacity(moved.len()); - for m in &moved { - if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) { - taken[j] = true; - indices.push(j); + let mut out = Vec::with_capacity(notes.len()); + for m in notes { + if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken.get(j).copied().unwrap_or(true) && n == m) { + if let Some(t) = taken.get_mut(j) { + *t = true; + } + out.push(j); } } - indices.sort_unstable(); - Ok(json!({"moved": idx.len(), "indices": indices})) + out.sort_unstable(); + out } #[cfg(test)] @@ -182,6 +228,50 @@ mod tests { assert_eq!(notes["notes"][1]["start"], 720); } + fn keys_clip(e: &mut Engine, notes: &[(u8, i64, i64)]) -> u64 { + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap(); + for &(pitch, start, length) in notes { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": length})).unwrap(); + } + clip + } + + fn starts_pitches_lengths(e: &mut Engine, clip: u64) -> Vec<(i64, i64, i64)> { + let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + n["notes"] + .as_array() + .unwrap() + .iter() + .map(|n| (n["start"].as_i64().unwrap(), n["pitch"].as_i64().unwrap(), n["length"].as_i64().unwrap())) + .collect() + } + + #[test] + fn notes_move_with_copy_leaves_the_originals_and_reports_the_copies() { + let mut e = Engine::default(); + let clip = keys_clip(&mut e, &[(60, 0, 240), (64, 480, 240)]); + let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 960, "semitones": 2, "copy": true})).unwrap(); + assert_eq!(r["indices"], json!([2, 3])); + assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(0, 60, 240), (480, 64, 240), (960, 62, 240), (1440, 66, 240)]); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(starts_pitches_lengths(&mut e, clip).len(), 2, "one undo step removes the copies"); + } + + #[test] + fn notes_trim_moves_both_edges_of_every_note_in_one_step() { + let mut e = Engine::default(); + let clip = keys_clip(&mut e, &[(60, 480, 480), (64, 960, 240)]); + let r = e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0, 1], "end": 120})).unwrap(); + assert_eq!(r["indices"], json!([0, 1])); + assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(480, 60, 600), (960, 64, 360)]); + // A start trim keeps the end; it stops one tick short of it and never goes below zero. + e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0, 1], "start": -600})).unwrap(); + assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(0, 60, 1080), (360, 64, 960)]); + e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [1], "start": 5000, "end": -5000})).unwrap(); + assert_eq!(starts_pitches_lengths(&mut e, clip)[1].2, 1); + } + #[test] fn rejects_audio_tracks() { let mut e = Engine::default(); diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 2b72c80ae..c00e49648 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -1,48 +1,197 @@ -//! The MIDI Editor: a piano roll with a velocity lane, docked under the Edit window. +//! The MIDI Editor: a piano roll with a ruler and a velocity lane, docked under the Edit window. use crate::SoundApp; use crate::theme::{Tokens, bold, regular}; -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use soundcraft_model::{ClipContent, ClipId, Tool, TrackId}; -use soundcraft_time::TICKS_PER_QUARTER; +use soundcraft_midi::Note; +use soundcraft_model::{ClipContent, ClipId, EditMode, Tool, TrackId}; +use soundcraft_time::{TICKS_PER_QUARTER, TempoMap}; const KEY_W: f32 = 48.0; -const ROW_H: f32 = 9.0; +const RULER_H: f32 = 18.0; const VEL_H: f32 = 64.0; +/// Default, smallest and largest key row height (the vertical zoom). +const ROW_H: f32 = 9.0; +const ROW_MIN: f32 = 4.0; +const ROW_MAX: f32 = 28.0; +/// Widest note-edge trim handle; notes narrower than `TRIM_MIN_W` can only be moved. +const EDGE_W: f32 = 6.0; +const TRIM_MIN_W: f32 = 10.0; +/// Closest horizontal zoom: a sixteenth note 120 px wide. +const MAX_PX_PER_TICK: f32 = 1.0; #[derive(Debug, Clone, Default)] pub struct MidiEditorState { pub selected: Vec, pub drag: Option, + /// Vertical scroll in pixels from the top row (pitch 127). pub scroll: f32, pub clip: Option, /// Rubber-band selection start (screen position). pub band: Option, /// The note sounding on the track's instrument while a key or note is held. pub audition: Option<(TrackId, u8)>, + /// Horizontal zoom in pixels per tick (0: fit the clip to the width on the next frame). + pub px_per_tick: f32, + /// The tick at the roll's left edge, counted from the clip start. + pub view_tick: f64, + /// Key row height (0: the default). + pub row_h: f32, + /// A note being drawn with the Pencil. + pub draft: Option, + /// The last pointer position of a stroke in the velocity lane. + pub vel_last: Option, + /// Where everything was drawn on the last frame. + pub view: Option, } impl MidiEditorState { - /// Start editing `id`. Note indices only mean something inside one clip, so the selection and - /// any gesture in progress are dropped when the clip changes. + /// Start editing `id`. Note indices only mean something inside one clip, so the selection, + /// any gesture in progress and the view (zoom and scroll) are reset when the clip changes. fn switch_clip(&mut self, id: ClipId) { if self.clip != Some(id) { self.selected.clear(); self.drag = None; self.band = None; + self.draft = None; + self.vel_last = None; + self.px_per_tick = 0.0; + self.view_tick = 0.0; } self.clip = Some(id); } } +/// What a note drag changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DragKind { + Move, + TrimStart, + TrimEnd, +} + #[derive(Debug, Clone)] pub struct NoteDrag { pub index: usize, - pub resize: bool, + pub kind: DragKind, pub start: egui::Pos2, pub dx_ticks: i64, pub dy_semi: i64, + /// Alt (Option) was held when the drag started: the moved notes are copies. + pub copy: bool, +} + +/// A note the Pencil is drawing: its pitch and clip-relative start and end ticks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NoteDraft { + pub pitch: u8, + pub start: i64, + pub end: i64, +} + +/// The piano roll's screen geometry and its tick/pitch mapping, for one frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RollView { + pub ruler: Rect, + pub roll: Rect, + pub keys: Rect, + pub vel: Rect, + pub view_tick: f64, + pub px_per_tick: f32, + pub row_h: f32, + pub scroll: f32, +} + +impl RollView { + /// Screen x of a clip-relative tick. + pub fn x_of(&self, tick: i64) -> f32 { + self.roll.min.x + ((tick as f64 - self.view_tick) * f64::from(self.px_per_tick)) as f32 + } + /// Clip-relative tick at screen x. + pub fn tick_at(&self, x: f32) -> f64 { + self.view_tick + f64::from(x - self.roll.min.x) / f64::from(self.px_per_tick.max(f32::EPSILON)) + } + /// Top of the row for `pitch`. + pub fn y_of(&self, pitch: i32) -> f32 { + self.roll.min.y + (127 - pitch) as f32 * self.row_h - self.scroll + } + pub fn pitch_at(&self, y: f32) -> i32 { + 127 - ((y - self.roll.min.y + self.scroll) / self.row_h.max(1.0)).floor() as i32 + } + /// The note's rectangle (at least 3 px wide so short notes stay visible). + pub fn note_rect(&self, start: i64, length: i64, pitch: i32) -> Rect { + let x0 = self.x_of(start); + let y = self.y_of(pitch); + Rect::from_min_max(pos2(x0, y + 1.0), pos2(self.x_of(start.saturating_add(length)).max(x0 + 3.0), y + self.row_h - 1.0)) + } + /// The note under `pos` and the part of it: its edges trim, its body moves. + pub fn hit(&self, notes: &[Note], pos: Pos2) -> Option<(usize, DragKind)> { + notes.iter().enumerate().rev().find_map(|(i, n)| { + let r = self.note_rect(n.start, n.length, i32::from(n.pitch)).expand2(vec2(0.0, 1.0)); + r.contains(pos).then(|| (i, edge_kind(r, pos.x))) + }) + } + /// The velocity lane's value at screen y. + pub fn velocity_at(&self, y: f32) -> i64 { + let v = (self.vel.max.y - 2.0 - y) / (self.vel.height() - 4.0).max(1.0) * 127.0; + if v.is_finite() { v.round().clamp(1.0, 127.0) as i64 } else { 1 } + } +} + +/// Which part of a note rectangle `x` falls on. +fn edge_kind(r: Rect, x: f32) -> DragKind { + if r.width() < TRIM_MIN_W { + return DragKind::Move; + } + let edge = EDGE_W.min(r.width() / 4.0); + if x >= r.max.x - edge { + DragKind::TrimEnd + } else if x <= r.min.x + edge { + DragKind::TrimStart + } else { + DragKind::Move + } +} + +/// The nearest grid line to a clip-relative tick. Lines are counted from the session start, so +/// notes land on the bars and beats of the timeline wherever the clip starts. +fn snap_tick(tick: f64, base: i64, grid: i64) -> i64 { + let g = grid.max(1) as f64; + let abs = tick + base as f64; + if !abs.is_finite() { + return 0; + } + ((abs / g).round() * g) as i64 - base +} + +/// The grid line at or before a clip-relative tick. +fn floor_tick(tick: f64, base: i64, grid: i64) -> i64 { + let g = grid.max(1) as f64; + let abs = tick + base as f64; + if !abs.is_finite() { + return 0; + } + ((abs / g).floor() * g) as i64 - base +} + +/// Whether drags snap to the grid: in Grid modes, unless the suspend modifier is held (and the +/// other way round in Slip, Shuffle and Spot). +fn snaps(mode: EditMode, suspend: bool) -> bool { + matches!(mode, EditMode::Grid | EditMode::GridRelative) != suspend +} + +/// A note length as bars|beats|ticks. +fn length_label(map: &TempoMap, at: i64, len: i64) -> String { + let m = map.meter_at_tick(at); + let (bar, beat) = (m.ticks_per_bar().max(1), m.ticks_per_beat().max(1)); + format!("{}|{}|{:03}", len / bar, len % bar / beat, len % beat) +} + +/// A position as bars|beats|ticks. +fn position_label(map: &TempoMap, at: i64) -> String { + let bb = map.bar_beat_at_tick(at); + format!("{}|{}|{:03}", bb.bar, bb.beat, bb.tick) } /// The MIDI clip being edited: the first selected MIDI clip, else a MIDI clip under the insertion @@ -86,6 +235,11 @@ fn audition(app: &mut SoundApp, want: Option<(TrackId, u8, u8)>) { app.midi.audition = want.map(|(t, p, _)| (t, p)); } +/// Indices returned by a note command, for keeping the touched notes selected. +fn returned_indices(r: Result) -> Vec { + r.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default() +} + pub fn show(app: &mut SoundApp, ui: &mut Ui) { if !ui.input(|i| i.pointer.primary_down()) { audition(app, None); @@ -100,6 +254,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(header, 0.0, t.panel_bg2); ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); let Some(cid) = target_clip(app) else { + app.midi.view = None; let track = { let s = app.engine.session(); s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) @@ -123,11 +278,12 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; let ClipContent::Midi { sequence } = &clip.content else { return }; let track_color = app.engine.session().track(track).map_or([120, 120, 200], |t| t.color); + let cmd_key = if ui.ctx().os().is_mac() { "Cmd" } else { "Ctrl" }; ui.painter().text( pos2(header.min.x + 110.0, header.center().y), Align2::LEFT_CENTER, format!( - "{} · {} notes · {} selected · Pencil: add · drag: move/select · arrows: move · Delete: remove", + "{} · {} notes · {} selected · {cmd_key}+wheel: zoom · Alt+wheel: height · Alt-drag: copy · {cmd_key}-drag: snap on/off", clip.name, sequence.notes.len(), app.midi.selected.len() @@ -173,128 +329,272 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let sr = s.sample_rate; let base_tick = s.tempo.samples_to_ticks(clip.start, sr); let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1); - let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0)); + let ruler = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, header.max.y + RULER_H)); + let roll = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y), pos2(ruler.max.x, full.max.y - VEL_H - 4.0)); let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); - // Vertical range: centre on the notes the first time. - let rows = (roll.height() / ROW_H).floor() as i32; - if app.midi.clip != Some(cid) { + if !roll.is_positive() { + return; + } + if app.midi.row_h <= 0.0 { + app.midi.row_h = ROW_H; + } + // A new clip: fit it to the width and centre the rows on its notes. + if app.midi.clip != Some(cid) || app.midi.px_per_tick <= 0.0 { + let rows = (roll.height() / app.midi.row_h).floor() as i32; let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72); let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48); - app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H; + if app.midi.clip != Some(cid) { + app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * app.midi.row_h; + } app.midi.switch_clip(cid); + app.midi.px_per_tick = roll.width() / len_ticks as f32; + app.midi.view_tick = 0.0; + } + // Wheel: scroll the rows (Shift: sideways), Ctrl/Cmd zooms the time, Alt the row height. + let body = Rect::from_min_max(pos2(full.min.x, ruler.min.y), full.max); + let min_ppt = (roll.width() / (len_ticks as f32 * 2.0)).min(MAX_PX_PER_TICK); + if ui.rect_contains_pointer(body) { + let (delta, zoom, mods, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos())); + let m = &mut app.midi; + if zoom != 1.0 { + let x = hover.map_or(roll.center().x, |p| p.x.clamp(roll.min.x, roll.max.x)); + let anchor = m.view_tick + f64::from(x - roll.min.x) / f64::from(m.px_per_tick); + m.px_per_tick = (m.px_per_tick * zoom).clamp(min_ppt, MAX_PX_PER_TICK); + m.view_tick = anchor - f64::from(x - roll.min.x) / f64::from(m.px_per_tick); + } else if mods.alt && delta.y != 0.0 { + let y = hover.map_or(roll.center().y, |p| p.y.clamp(roll.min.y, roll.max.y)); + let rows_above = (y - roll.min.y + m.scroll) / m.row_h; + m.row_h = (m.row_h * (delta.y * 0.01).exp()).clamp(ROW_MIN, ROW_MAX); + m.scroll = rows_above * m.row_h - (y - roll.min.y); + } else { + let (dx, dy) = if mods.shift && delta.x == 0.0 { (delta.y, 0.0) } else { (delta.x, delta.y) }; + m.view_tick -= f64::from(dx) / f64::from(m.px_per_tick); + m.scroll -= dy; + } + } + { + let m = &mut app.midi; + m.px_per_tick = m.px_per_tick.clamp(min_ppt, MAX_PX_PER_TICK); + let visible = f64::from(roll.width() / m.px_per_tick); + m.view_tick = m.view_tick.clamp(0.0, (len_ticks as f64 - visible * 0.5).max(0.0)); + m.scroll = m.scroll.clamp(0.0, (128.0 * m.row_h - roll.height()).max(0.0)); } - if ui.rect_contains_pointer(roll) { - app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y); + // Follow the playhead a page at a time while it plays through the clip. + let playing = app.is_playing(); + let play_tick = s.tempo.samples_to_ticks_f(app.position(), sr) - base_tick as f64; + if playing { + ui.ctx().request_repaint(); + let visible = f64::from(roll.width() / app.midi.px_per_tick); + let follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center"); + if follow && app.midi.drag.is_none() && (0.0..len_ticks as f64).contains(&play_tick) { + let v = app.midi.view_tick; + if play_tick < v || play_tick > v + visible { + app.midi.view_tick = play_tick.clamp(0.0, (len_ticks as f64 - visible * 0.5).max(0.0)); + } + } } - app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0)); - let scroll = app.midi.scroll; - let px_per_tick = roll.width() / len_ticks as f32; - let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick; - let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll; - let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32; + let rv = RollView { + ruler, + roll, + keys, + vel, + view_tick: app.midi.view_tick, + px_per_tick: app.midi.px_per_tick, + row_h: app.midi.row_h, + scroll: app.midi.scroll, + }; + app.midi.view = Some(rv); + let row_h = rv.row_h; + let tick_lo = rv.tick_at(roll.min.x).floor() as i64; + let tick_hi = rv.tick_at(roll.max.x).ceil() as i64; let painter = ui.painter().with_clip_rect(roll.union(keys)); // Background rows and keys. - let top = pitch_at(roll.min.y); + let hover = ui.ctx().pointer_latest_pos().filter(|p| roll.union(keys).contains(*p)); + let hover_pitch = hover.map(|p| rv.pitch_at(p.y)); + let top = rv.pitch_at(roll.min.y); + let rows = (roll.height() / row_h).ceil() as i32; + let (white_row, black_row) = (t.playlist_alt, t.playlist_bg.lerp_to_gamma(Color32::BLACK, 0.2)); for r in 0..=rows + 1 { let pitch = top - r; - let y = y_of(pitch); - let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H)); - painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) }); - let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0)); - painter.rect_filled(key, 1.0, if is_black(pitch) { Color32::from_rgb(20, 20, 20) } else { Color32::from_rgb(225, 225, 225) }); - if pitch.rem_euclid(12) == 0 && (0..=127).contains(&pitch) { - painter.text( - pos2(key.max.x - 3.0, key.center().y), - Align2::RIGHT_CENTER, - soundcraft_midi::note_name(pitch as u8), - regular(8.0), - Color32::from_rgb(40, 40, 40), - ); - painter.line_segment([pos2(roll.min.x, y + ROW_H), pos2(roll.max.x, y + ROW_H)], Stroke::new(1.0, Color32::from_rgb(64, 64, 68))); + if !(0..=127).contains(&pitch) { + continue; + } + let y = rv.y_of(pitch); + let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), row_h)); + painter.rect_filled(row, 0.0, if is_black(pitch) { black_row } else { white_row }); + let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, (row_h - 1.0).max(1.0))); + let key_fill = if is_black(pitch) { Color32::from_rgb(20, 20, 20) } else { Color32::from_rgb(225, 225, 225) }; + let hovered = hover_pitch == Some(pitch); + painter.rect_filled(key, 1.0, if hovered { key_fill.lerp_to_gamma(t.accent, 0.5) } else { key_fill }); + if (pitch.rem_euclid(12) == 0 || hovered) && row_h >= 7.0 { + let ink = if is_black(pitch) && !hovered { Color32::from_rgb(220, 220, 220) } else { Color32::from_rgb(40, 40, 40) }; + painter.text(pos2(key.max.x - 3.0, key.center().y), Align2::RIGHT_CENTER, soundcraft_midi::note_name(pitch as u8), regular(8.0), ink); + } + if pitch.rem_euclid(12) == 0 { + painter.line_segment([pos2(roll.min.x, y + row_h), pos2(roll.max.x, y + row_h)], Stroke::new(1.0, t.bar_line)); + } + } + // Ruler, grid, beat and bar lines, all on the session's timeline. + ui.painter().rect_filled(Rect::from_min_max(pos2(full.min.x, ruler.min.y), ruler.max), 0.0, t.ruler_bg); + let rp = ui.painter().with_clip_rect(ruler); + if grid_ticks as f32 * rv.px_per_tick > 6.0 { + let mut g = floor_tick(tick_lo as f64, base_tick, grid_ticks); + while g <= tick_hi { + let x = rv.x_of(g); + painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, t.grid_line)); + g += grid_ticks; } } - // Grid. - let mut tick = 0; - while tick <= len_ticks { - let bar = tick % (TICKS_PER_QUARTER * 4) == 0; - let x = x_of(tick); - if grid_ticks as f32 * px_per_tick > 4.0 || bar { - painter.line_segment( - [pos2(x, roll.min.y), pos2(x, roll.max.y)], - Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }), - ); + let first_bar = s.tempo.bar_beat_at_tick(base_tick.saturating_add(tick_lo).max(0)).bar; + let label_every = { + let m = s.tempo.meter_at_tick(base_tick); + let px = m.ticks_per_bar() as f32 * rv.px_per_tick; + (40.0 / px.max(0.01)).ceil().max(1.0) as i64 + }; + for bar in (first_bar..).take(4096) { + let at = s.tempo.bar_start_tick(bar); + if at - base_tick > tick_hi { + break; + } + let m = s.tempo.meter_at_tick(at); + let beat = m.ticks_per_beat(); + if beat as f32 * rv.px_per_tick > 10.0 { + for k in 1..i64::from(m.numerator.max(1)) { + let x = rv.x_of(at + k * beat - base_tick); + painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, t.bar_line.gamma_multiply(0.7))); + rp.line_segment([pos2(x, ruler.max.y - 4.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.ruler_tick)); + } + } + let x = rv.x_of(at - base_tick); + painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, t.bar_line)); + rp.line_segment([pos2(x, ruler.min.y + 3.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.ruler_tick)); + if bar.rem_euclid(label_every) == 1 % label_every { + rp.text(pos2(x + 3.0, ruler.center().y - 1.0), Align2::LEFT_CENTER, bar.to_string(), regular(10.0), t.ruler_text); } - tick += grid_ticks; } - // Notes. + // Past the clip's end: shaded. + let end_x = rv.x_of(len_ticks); + if end_x < roll.max.x { + painter.rect_filled(Rect::from_min_max(pos2(end_x.max(roll.min.x), roll.min.y), roll.max), 0.0, Color32::from_black_alpha(90)); + } + // Clicking the ruler moves the playhead there. + let ruler_resp = ui.interact(ruler, ui.id().with(("ruler", cid.0)), Sense::click()); + if ruler_resp.clicked() + && let Some(p) = ruler_resp.interact_pointer_pos() + { + let tick = base_tick.saturating_add(rv.tick_at(p.x).round() as i64).max(0); + let _ = app.run("transport.locate", json!({"at": s.tempo.tick_to_samples(tick, sr)})); + } + // Interaction state for this frame. + let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag()); + let mods = ui.input(|i| i.modifiers); + let snapping = snaps(s.edit.edit_mode, mods.command); + let relative = s.edit.edit_mode == EditMode::GridRelative; + // Notes, with the drag in progress previewed. let base = egui::Color32::from_rgb(track_color[0], track_color[1], track_color[2]); let drag = app.midi.drag.clone(); - for (i, n) in sequence.notes.iter().enumerate() { - let (mut st, mut ln, mut p) = (n.start, n.length, i32::from(n.pitch)); - if let Some(d) = &drag - && (d.index == i || (!d.resize && app.midi.selected.contains(&i) && app.midi.selected.contains(&d.index))) - { - if d.resize { - if d.index == i { - ln = (ln + d.dx_ticks).max(grid_ticks.min(ln).max(1)); - } - } else { - st = (st + d.dx_ticks).max(0); - p = (p + d.dy_semi as i32).clamp(0, 127); + let preview = |i: usize, n: &Note| -> (i64, i64, i32) { + let (st, ln, p) = (n.start, n.length, i32::from(n.pitch)); + let Some(d) = drag.as_ref().filter(|_| app.midi.selected.contains(&i)) else { return (st, ln, p) }; + match d.kind { + DragKind::Move => ((st + d.dx_ticks).max(0), ln, (p + d.dy_semi as i32).clamp(0, 127)), + DragKind::TrimEnd => (st, (ln + d.dx_ticks).max(1), p), + DragKind::TrimStart => { + let end = st + ln; + let s2 = (st + d.dx_ticks).clamp(0, end - 1); + (s2, end - s2, p) } } - let r = Rect::from_min_max(pos2(x_of(st), y_of(p) + 1.0), pos2(x_of(st + ln).max(x_of(st) + 3.0), y_of(p) + ROW_H - 1.0)); - let sel = app.midi.selected.contains(&i); + }; + let fill_of = |n: &Note| { let k = 0.45 + 0.55 * f32::from(n.velocity) / 127.0; - let fill = if sel { - Color32::WHITE - } else { - Color32::from_rgb((f32::from(base.r()) * k) as u8, (f32::from(base.g()) * k) as u8, (f32::from(base.b()) * k) as u8) - }; - painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + Color32::from_rgb((f32::from(base.r()) * k) as u8, (f32::from(base.g()) * k) as u8, (f32::from(base.b()) * k) as u8) + }; + let copying = drag.as_ref().is_some_and(|d| d.copy && d.kind == DragKind::Move); + for (i, n) in sequence.notes.iter().enumerate() { + let sel = app.midi.selected.contains(&i); + let (st, ln, p) = if copying { (n.start, n.length, i32::from(n.pitch)) } else { preview(i, n) }; + let r = rv.note_rect(st, ln, p); + if r.max.x < roll.min.x || r.min.x > roll.max.x { + continue; + } + let fill = if sel { fill_of(n).lerp_to_gamma(Color32::WHITE, 0.65) } else { fill_of(n) }; + let stroke = if sel { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, Color32::BLACK) }; + painter.rect(r, 2.0, fill, stroke, StrokeKind::Inside); + } + if copying { + for (i, n) in sequence.notes.iter().enumerate().filter(|(i, _)| app.midi.selected.contains(i)) { + let (st, ln, p) = preview(i, n); + painter.rect(rv.note_rect(st, ln, p), 2.0, fill_of(n).gamma_multiply(0.7), Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + } + if let Some(d) = app.midi.draft { + painter.rect(rv.note_rect(d.start, d.end - d.start, i32::from(d.pitch)), 2.0, base, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + // Playhead (while playing) or the insertion point. + let px = rv.x_of(play_tick.round() as i64); + if (roll.min.x..=roll.max.x).contains(&px) { + let (col, w) = if playing { (t.playhead, 1.5) } else { (t.insertion, 1.0) }; + ui.painter().line_segment([pos2(px, ruler.min.y), pos2(px, roll.max.y)], Stroke::new(w, col)); } // Interaction. - let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag()); - let hit = |pos: egui::Pos2| -> Option<(usize, bool)> { - sequence.notes.iter().enumerate().rev().find_map(|(i, n)| { - let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length).max(x_of(n.start) + 3.0), y_of(i32::from(n.pitch)) + ROW_H), - ); - r.contains(pos).then_some((i, pos.x > r.max.x - 5.0)) - }) - }; - let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; + let hit = |pos: Pos2| rv.hit(&sequence.notes, pos); + if let Some(p) = hover.filter(|p| roll.contains(*p)) + && app.midi.drag.is_none() + && matches!(hit(p), Some((_, DragKind::TrimStart | DragKind::TrimEnd))) + { + ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal); + } // Audition: a held piano key plays its pitch (sliding across keys plays each one); a held - // note plays at its velocity, following the pitch it is dragged to. + // note plays at its velocity, following the pitch it is dragged to; the Pencil plays the note + // it is drawing. let key_resp = ui.interact(keys, ui.id().with(("keys", cid.0)), Sense::click_and_drag()); let pointer = ui.ctx().pointer_latest_pos(); + let origin = ui.input(|i| i.pointer.press_origin()); let want = if key_resp.is_pointer_button_down_on() { - pointer.filter(|p| keys.contains(*p)).map(|p| (pitch_at(p.y).clamp(0, 127) as u8, 100)) + pointer.filter(|p| keys.contains(*p)).map(|p| (rv.pitch_at(p.y).clamp(0, 127) as u8, 100)) } else if resp.is_pointer_button_down_on() { - ui.input(|i| i.pointer.press_origin()).and_then(hit).and_then(|(i, _)| sequence.notes.get(i)).map(|n| { - let dy = app.midi.drag.as_ref().filter(|d| !d.resize).map_or(0, |d| d.dy_semi); - ((i64::from(n.pitch) + dy).clamp(0, 127) as u8, n.velocity.max(1)) - }) + match origin.and_then(hit) { + Some((i, _)) => sequence.notes.get(i).map(|n| { + let dy = app.midi.drag.as_ref().filter(|d| d.kind == DragKind::Move).map_or(0, |d| d.dy_semi); + ((i64::from(n.pitch) + dy).clamp(0, 127) as u8, n.velocity.max(1)) + }), + None if s.edit.tool == Tool::Pencil => { + app.midi.draft.map(|d| d.pitch).or_else(|| origin.map(|o| rv.pitch_at(o.y).clamp(0, 127) as u8)).map(|p| (p, 100)) + } + None => None, + } } else { None }; audition(app, want.map(|(p, v)| (track, p, v))); if resp.drag_started() - && let Some(p) = resp.interact_pointer_pos() + && let Some(p) = origin.or_else(|| resp.interact_pointer_pos()) { - if let Some((i, edge)) = hit(p) { - if !app.midi.selected.contains(&i) { - app.midi.selected = vec![i]; + match hit(p) { + Some((i, kind)) => { + if !app.midi.selected.contains(&i) { + if mods.shift { + app.midi.selected.push(i); + } else { + app.midi.selected = vec![i]; + } + } + let copy = kind == DragKind::Move && mods.alt; + app.midi.drag = Some(NoteDrag { index: i, kind, start: p, dx_ticks: 0, dy_semi: 0, copy }); } - app.midi.drag = Some(NoteDrag { index: i, resize: edge, start: p, dx_ticks: 0, dy_semi: 0 }); - } else { - app.midi.band = Some(p); + None if s.edit.tool == Tool::Pencil => { + let at = rv.tick_at(p.x); + let start = if snapping { floor_tick(at, base_tick, grid_ticks) } else { at.round() as i64 }.max(0); + let end = start + if snapping { grid_ticks } else { 1 }; + app.midi.draft = Some(NoteDraft { pitch: rv.pitch_at(p.y).clamp(0, 127) as u8, start, end }); + } + None => app.midi.band = Some(p), } } // Rubber-band selection on empty space. - if let (Some(a), Some(b)) = (app.midi.band, ui.ctx().pointer_latest_pos()) { + if let (Some(a), Some(b)) = (app.midi.band, pointer) { let band = Rect::from_two_pos(a, b); ui.painter().rect( band, @@ -303,48 +603,101 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { Stroke::new(1.0, Color32::from_rgb(120, 170, 255)), StrokeKind::Inside, ); - if resp.drag_stopped() { - app.midi.selected = sequence + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + let picked = sequence .notes .iter() .enumerate() - .filter(|(_, n)| { - let r = Rect::from_min_max( - pos2(x_of(n.start), y_of(i32::from(n.pitch))), - pos2(x_of(n.start + n.length), y_of(i32::from(n.pitch)) + ROW_H), - ); - r.intersects(band) - }) - .map(|(i, _)| i) - .collect(); + .filter(|(_, n)| rv.note_rect(n.start, n.length, i32::from(n.pitch)).intersects(band)) + .map(|(i, _)| i); + if mods.shift { + for i in picked { + if !app.midi.selected.contains(&i) { + app.midi.selected.push(i); + } + } + } else { + app.midi.selected = picked.collect(); + } app.midi.band = None; } } - if resp.dragged() - && let (Some(d), Some(p)) = (&mut app.midi.drag, resp.interact_pointer_pos()) + // Drag in progress: snapped so the grabbed note's edge lands on the grid (Grid), or moved by + // whole grid steps keeping its offset (Relative Grid). + let mut info: Option = None; + if let (Some(p), Some(d)) = (pointer, app.midi.drag.as_mut()) + && let Some(n) = sequence.notes.get(d.index) { - d.dx_ticks = snap((p.x - d.start.x) / px_per_tick); - d.dy_semi = -((p.y - d.start.y) / ROW_H).round() as i64; + let raw = f64::from(p.x - d.start.x) / f64::from(rv.px_per_tick); + let edge = match d.kind { + DragKind::TrimEnd => n.end(), + DragKind::Move | DragKind::TrimStart => n.start, + }; + d.dx_ticks = if !snapping { + raw.round() as i64 + } else if relative { + (raw / grid_ticks as f64).round() as i64 * grid_ticks + } else { + snap_tick(edge as f64 + raw, base_tick, grid_ticks) - edge + }; + if d.kind == DragKind::Move { + d.dy_semi = -((p.y - d.start.y) / row_h).round() as i64; + } + let pitch = (i64::from(n.pitch) + d.dy_semi).clamp(0, 127) as u8; + info = Some(match d.kind { + DragKind::Move => { + format!("{} {}", soundcraft_midi::note_name(pitch), position_label(&s.tempo, base_tick + (n.start + d.dx_ticks).max(0))) + } + DragKind::TrimEnd => format!( + "{} length {}", + soundcraft_midi::note_name(pitch), + length_label(&s.tempo, base_tick + n.start, (n.length + d.dx_ticks).max(1)) + ), + DragKind::TrimStart => { + let st = (n.start + d.dx_ticks).clamp(0, n.end() - 1); + format!("{} start {}", soundcraft_midi::note_name(pitch), position_label(&s.tempo, base_tick + st)) + } + }); } - if resp.drag_stopped() - && let Some(d) = app.midi.drag.take() - { - if d.resize { - if let Some(n) = sequence.notes.get(d.index) { - let _ = app.run("midi.note_edit", json!({"clip": cid.0, "index": d.index, "length_ticks": (n.length + d.dx_ticks).max(1)})); + if let (Some(p), Some(d)) = (pointer, app.midi.draft.as_mut()) { + let at = rv.tick_at(p.x); + let min = if snapping { grid_ticks } else { 1 }; + let end = if snapping { snap_tick(at, base_tick, grid_ticks) } else { at.round() as i64 }; + d.end = end.max(d.start + min); + info = Some(format!("{} length {}", soundcraft_midi::note_name(d.pitch), length_label(&s.tempo, base_tick + d.start, d.end - d.start))); + } + let released = resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()); + if released && let Some(d) = app.midi.drag.take() { + let sel = app.midi.selected.clone(); + match d.kind { + DragKind::Move if d.dx_ticks != 0 || d.dy_semi != 0 => { + let r = + app.run("midi.notes_move", json!({"clip": cid.0, "indices": sel, "ticks": d.dx_ticks, "semitones": d.dy_semi, "copy": d.copy})); + app.midi.selected = returned_indices(r); } - } else if d.dx_ticks != 0 || d.dy_semi != 0 { - let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": d.dx_ticks, "semitones": d.dy_semi})); - app.midi.selected.clear(); + DragKind::TrimEnd | DragKind::TrimStart if d.dx_ticks != 0 => { + let mut p = json!({"clip": cid.0, "indices": sel}); + p[if d.kind == DragKind::TrimEnd { "end" } else { "start" }] = json!(d.dx_ticks); + let r = app.run("midi.notes_trim", p); + app.midi.selected = returned_indices(r); + } + _ => {} } } + if released && let Some(d) = app.midi.draft.take() { + let _ = app + .run("midi.note_add", json!({"clip": cid.0, "pitch": d.pitch, "start_ticks": d.start, "length_ticks": d.end - d.start, "velocity": 100})); + app.midi.selected.clear(); + } if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { match hit(p) { Some((i, _)) => { - if ui.input(|x| x.modifiers.shift) { - if !app.midi.selected.contains(&i) { + if mods.shift { + if let Some(k) = app.midi.selected.iter().position(|&x| x == i) { + app.midi.selected.remove(k); + } else { app.midi.selected.push(i); } } else { @@ -352,8 +705,9 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } } None if s.edit.tool == Tool::Pencil => { - let pitch = pitch_at(p.y).clamp(0, 127); - let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks; + let pitch = rv.pitch_at(p.y).clamp(0, 127); + let at = rv.tick_at(p.x); + let start = if snapping { floor_tick(at, base_tick, grid_ticks) } else { at.round() as i64 }; let _ = app.run( "midi.note_add", json!({"clip": cid.0, "pitch": pitch, "start_ticks": start.max(0), "length_ticks": grid_ticks, "velocity": 100}), @@ -363,6 +717,28 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { None => app.midi.selected.clear(), } } + // The note under the pointer, when nothing is being dragged. + if info.is_none() + && let Some(p) = hover.filter(|p| roll.contains(*p)) + { + info = Some(match hit(p).and_then(|(i, _)| sequence.notes.get(i)) { + Some(n) => format!( + "{} vel {} {} length {}", + soundcraft_midi::note_name(n.pitch), + n.velocity, + position_label(&s.tempo, base_tick + n.start), + length_label(&s.tempo, base_tick + n.start, n.length) + ), + None => soundcraft_midi::note_name(rv.pitch_at(p.y).clamp(0, 127) as u8).to_string(), + }); + } + if let (Some(text), Some(p)) = (info, pointer) { + let tip = ui.painter().with_clip_rect(full); + let galley = tip.layout_no_wrap(text, regular(10.5), t.text); + let at = pos2((p.x + 14.0).min(full.max.x - galley.size().x - 6.0), (p.y - 20.0).max(full.min.y + 2.0)); + tip.rect_filled(Rect::from_min_size(at, galley.size()).expand(3.0), 3.0, t.panel_bg2.gamma_multiply(0.95)); + tip.galley(at, galley, t.text); + } // Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid. if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() { let (all, up, down, left, right, shift) = ui.input(|i| { @@ -397,7 +773,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let moved = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi})); // The engine re-sorts the notes: follow the moved ones to their new indices, so the // selection never points at other notes and the arrows can keep nudging it. - app.midi.selected = moved.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default(); + app.midi.selected = returned_indices(moved); } } if !app.midi.selected.is_empty() @@ -407,24 +783,69 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); app.midi.selected.clear(); } - // Velocity lane. - ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); + velocity_lane(app, ui, &rv, cid, &sequence.notes, base, full); +} + +/// The velocity lane: one stem per note. Dragging draws the velocity of every stem the pointer +/// passes; with notes selected only theirs change, so one note of a chord can be set on its own. +fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, rv: &RollView, cid: ClipId, notes: &[Note], base: Color32, full: Rect) { + let t = Tokens::current(); + let vel = rv.vel; + ui.painter().rect_filled(vel, 0.0, t.playlist_bg.lerp_to_gamma(Color32::BLACK, 0.25)); ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); - let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag()); - for (i, n) in sequence.notes.iter().enumerate() { - let x = x_of(n.start); + let painter = ui.painter().with_clip_rect(vel); + let selected = app.midi.selected.clone(); + let editable = |i: usize| selected.is_empty() || selected.contains(&i); + for (i, n) in notes.iter().enumerate() { + let x = rv.x_of(n.start) + 1.0; let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0; - let col = if app.midi.selected.contains(&i) { Color32::WHITE } else { base }; - ui.painter().line_segment([pos2(x + 1.0, vel.max.y - 2.0), pos2(x + 1.0, vel.max.y - 2.0 - h)], Stroke::new(3.0, col)); - ui.painter().circle_filled(pos2(x + 1.0, vel.max.y - 2.0 - h), 2.5, col); - } - if (vresp.clicked() || vresp.dragged()) - && let Some(p) = vresp.interact_pointer_pos() - && let Some((i, n)) = sequence.notes.iter().enumerate().min_by_key(|(_, n)| ((x_of(n.start) - p.x).abs() * 10.0) as i64) - && (x_of(n.start) - p.x).abs() < 8.0 + let col = if selected.contains(&i) { + base.lerp_to_gamma(Color32::WHITE, 0.65) + } else if editable(i) { + base + } else { + base.gamma_multiply(0.35) + }; + painter.line_segment([pos2(x, vel.max.y - 2.0), pos2(x, vel.max.y - 2.0 - h)], Stroke::new(3.0, col)); + painter.circle_filled(pos2(x, vel.max.y - 2.0 - h), 2.5, col); + } + let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag()); + let down = vresp.is_pointer_button_down_on() || vresp.clicked(); + let Some(p) = vresp.interact_pointer_pos().filter(|_| down) else { + if app.midi.vel_last.take().is_some() { + app.engine.end_merge(); + } + return; + }; + // The segment since the last frame, so a quick stroke still sets every stem it crossed. + let a = app.midi.vel_last.unwrap_or(p); + let (lo, hi) = (a.x.min(p.x) - 3.0, a.x.max(p.x) + 3.0); + let mut targets: Vec<(usize, i64)> = Vec::new(); + for (i, n) in notes.iter().enumerate() { + let x = rv.x_of(n.start) + 1.0; + if editable(i) && (lo..=hi).contains(&x) { + let f = if (p.x - a.x).abs() < 0.5 { 1.0 } else { ((x - a.x) / (p.x - a.x)).clamp(0.0, 1.0) }; + targets.push((i, rv.velocity_at(a.y + (p.y - a.y) * f))); + } + } + // A click beside the stems still picks the nearest one. + if targets.is_empty() + && app.midi.vel_last.is_none() + && let Some((i, _)) = notes + .iter() + .enumerate() + .filter(|(i, _)| editable(*i)) + .map(|(i, n)| (i, (rv.x_of(n.start) - p.x).abs())) + .filter(|(_, d)| *d < 8.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) { - let v = (((vel.max.y - 2.0 - p.y) / (vel.height() - 4.0)) * 127.0).round().clamp(1.0, 127.0) as i64; - let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); + targets.push((i, rv.velocity_at(p.y))); + } + app.midi.vel_last = Some(p); + for (i, v) in targets { + if notes.get(i).is_some_and(|n| i64::from(n.velocity) != v) { + let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0)); + } } } @@ -442,4 +863,39 @@ mod tests { assert!(st.drag.is_none() && st.band.is_none()); assert_eq!(st.clip, Some(ClipId(2))); } + + #[test] + fn snapping_counts_grid_lines_from_the_session_start() { + // A clip starting 100 ticks after a line: its tick 0 is not on the grid. + assert_eq!(snap_tick(0.0, 100, 240), -100); + assert_eq!(snap_tick(150.0, 100, 240), 140); + assert_eq!(floor_tick(379.0, 100, 240), 140); + assert_eq!(snap_tick(f64::NAN, 0, 240), 0); + } + + #[test] + fn the_suspend_modifier_flips_snapping() { + assert!(snaps(EditMode::Grid, false)); + assert!(!snaps(EditMode::Grid, true)); + assert!(snaps(EditMode::GridRelative, false)); + assert!(!snaps(EditMode::Slip, false)); + assert!(snaps(EditMode::Slip, true)); + } + + #[test] + fn narrow_notes_only_move_and_wide_ones_trim_at_their_edges() { + let narrow = Rect::from_min_size(pos2(0.0, 0.0), vec2(8.0, 8.0)); + assert_eq!(edge_kind(narrow, 7.5), DragKind::Move); + let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 8.0)); + assert_eq!(edge_kind(wide, 97.0), DragKind::TrimEnd); + assert_eq!(edge_kind(wide, 2.0), DragKind::TrimStart); + assert_eq!(edge_kind(wide, 50.0), DragKind::Move); + } + + #[test] + fn lengths_and_positions_read_as_bars_beats_ticks() { + let map = TempoMap::default(); + assert_eq!(length_label(&map, 0, TICKS_PER_QUARTER * 5 + 120), "1|1|120"); + assert_eq!(position_label(&map, TICKS_PER_QUARTER * 4), "2|1|000"); + } } diff --git a/crates/ui-egui/tests/midi_editor_gestures.rs b/crates/ui-egui/tests/midi_editor_gestures.rs new file mode 100644 index 000000000..ebfa3dd71 --- /dev/null +++ b/crates/ui-egui/tests/midi_editor_gestures.rs @@ -0,0 +1,177 @@ +//! MIDI Editor mouse gestures: drawing, copying, trimming, velocity strokes and zoom. +use egui::{Event, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_ui_egui::midi_editor::RollView; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + app: SoundApp, + ctx: egui::Context, + clip: Value, +} + +impl Rig { + /// A MIDI track with a 16-beat clip open in the MIDI Editor, Grid mode on a 1/16 grid. + fn new(notes: &[(u8, i64, i64)]) -> Self { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name": "Keys", "kind": "midi"})).unwrap(); + let q = app.engine.session().sample_rate.samples(0.5); + let clip = app.run("midi.new_clip", json!({"track": "Keys", "start": 0, "end": 16 * q})).unwrap()["clip"].clone(); + for &(pitch, start, length) in notes { + app.run("midi.note_add", json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": length})).unwrap(); + } + app.run("edit.select", json!({"clips": [clip]})).unwrap(); + app.run("view.grid", json!({"value": "1/16"})).unwrap(); + app.run("edit.mode", json!({"mode": "grid"})).unwrap(); + app.ui.show_midi_editor = true; + let mut r = Rig { app, ctx: egui::Context::default(), clip }; + r.frames(6, Modifiers::NONE); + r + } + + fn frame(&mut self, events: Vec, modifiers: Modifiers) { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let app = &mut self.app; + self.ctx + .run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + } + + fn frames(&mut self, n: usize, modifiers: Modifiers) { + for _ in 0..n { + self.frame(vec![], modifiers); + } + } + + fn view(&self) -> RollView { + self.app.midi.view.unwrap() + } + + /// The centre of `pitch`'s row at a clip tick. + fn at(&self, tick: i64, pitch: i32) -> Pos2 { + let v = self.view(); + pos2(v.x_of(tick), v.y_of(pitch) + v.row_h / 2.0) + } + + fn drag(&mut self, from: Pos2, to: Pos2, modifiers: Modifiers) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + self.frame(vec![Event::PointerMoved(from)], modifiers); + self.frame(vec![button(from, true)], modifiers); + for step in 1..=8 { + self.frame(vec![Event::PointerMoved(from.lerp(to, step as f32 / 8.0))], modifiers); + } + self.frame(vec![button(to, false)], modifiers); + self.frames(2, Modifiers::NONE); + } + + fn click(&mut self, at: Pos2, modifiers: Modifiers) { + self.frame(vec![Event::PointerMoved(at)], modifiers); + self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers }], modifiers); + self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers }], modifiers); + self.frames(2, Modifiers::NONE); + } + + /// (start, pitch, length, velocity) of every note. + fn notes(&mut self) -> Vec<(i64, i64, i64, i64)> { + let n = self.app.run("midi.notes", json!({"clip": self.clip})).unwrap(); + n["notes"] + .as_array() + .unwrap() + .iter() + .map(|n| (n["start"].as_i64().unwrap(), n["pitch"].as_i64().unwrap(), n["length"].as_i64().unwrap(), n["velocity"].as_i64().unwrap())) + .collect() + } +} + +#[test] +fn a_pencil_drag_draws_a_note_as_long_as_the_drag() { + let mut r = Rig::new(&[]); + r.app.run("edit.tool", json!({"tool": "pencil"})).unwrap(); + let (from, to) = (r.at(490, 60), r.at(1440, 60)); + r.drag(from, to, Modifiers::NONE); + // The start snaps back to its sixteenth, the end to the nearest one. + assert_eq!(r.notes(), vec![(480, 60, 960, 100)]); +} + +#[test] +fn alt_dragging_notes_copies_them_and_selects_the_copies() { + let mut r = Rig::new(&[(60, 0, 480), (64, 0, 480)]); + r.app.midi.selected = vec![0, 1]; + let (from, to) = (r.at(240, 60), r.at(240 + 960, 62)); + r.drag(from, to, Modifiers::ALT); + assert_eq!(r.notes(), vec![(0, 60, 480, 100), (0, 64, 480, 100), (960, 62, 480, 100), (960, 66, 480, 100)]); + assert_eq!(r.app.midi.selected, vec![2, 3]); + r.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(r.notes().len(), 2, "the copy is one undo step"); +} + +#[test] +fn moving_notes_past_others_keeps_them_selected() { + let mut r = Rig::new(&[(60, 0, 480), (62, 480, 480), (64, 960, 480)]); + r.app.midi.selected = vec![0]; + let (from, to) = (r.at(240, 60), r.at(240 + 1440, 60)); + r.drag(from, to, Modifiers::NONE); + assert_eq!(r.notes()[2], (1440, 60, 480, 100)); + assert_eq!(r.app.midi.selected, vec![2]); +} + +#[test] +fn trimming_one_selected_note_trims_them_all() { + let mut r = Rig::new(&[(60, 0, 480), (64, 960, 960)]); + r.app.midi.selected = vec![0, 1]; + let edge = r.at(480, 60) - vec2(2.0, 0.0); + r.drag(edge, edge + vec2(r.view().px_per_tick * 240.0, 0.0), Modifiers::NONE); + assert_eq!(r.notes(), vec![(0, 60, 720, 100), (960, 64, 1200, 100)]); + assert_eq!(r.app.midi.selected, vec![0, 1]); + // The start edge, with the grid suspended: the end stays put. + let start = r.at(960, 64) + vec2(2.0, 0.0); + r.drag(start, start + vec2(r.view().px_per_tick * 100.0, 0.0), Modifiers::COMMAND); + let n = r.notes(); + assert!(n[0].0 > 0 && n[0].0 < 120 && n[0].0 + n[0].2 == 720, "{n:?}"); + assert!(n[1].0 > 960 && n[1].0 + n[1].2 == 2160, "{n:?}"); +} + +#[test] +fn with_one_chord_note_selected_the_velocity_lane_sets_only_that_note() { + let mut r = Rig::new(&[(60, 0, 480), (64, 0, 480), (67, 0, 480)]); + r.app.midi.selected = vec![1]; + let v = r.view(); + r.click(pos2(v.x_of(0) + 1.0, v.vel.min.y + 2.0), Modifiers::NONE); + let vel: Vec = r.notes().iter().map(|n| n.3).collect(); + assert_eq!(vel, vec![100, 127, 100]); +} + +#[test] +fn a_velocity_stroke_sets_every_note_it_crosses_in_one_undo_step() { + let mut r = Rig::new(&[(60, 0, 240), (60, 480, 240), (60, 960, 240), (60, 1440, 240)]); + let v = r.view(); + let y = v.vel.max.y - 2.0 - (v.vel.height() - 4.0) * 0.5; + r.drag(pos2(v.x_of(0) - 4.0, y), pos2(v.x_of(1440) + 4.0, y), Modifiers::NONE); + assert!(r.notes().iter().all(|n| (63..=65).contains(&n.3)), "{:?}", r.notes()); + r.app.run("edit.undo", json!({})).unwrap(); + assert!(r.notes().iter().all(|n| n.3 == 100), "{:?}", r.notes()); +} + +#[test] +fn zooming_keeps_the_tick_under_the_pointer_in_place() { + let mut r = Rig::new(&[(60, 0, 480)]); + let before = r.view(); + let p = r.at(1920, 60); + let tick = before.tick_at(p.x); + r.frame(vec![Event::PointerMoved(p)], Modifiers::NONE); + r.frame(vec![Event::Zoom(2.0)], Modifiers::NONE); + r.frames(1, Modifiers::NONE); + let after = r.view(); + assert!((after.px_per_tick / before.px_per_tick - 2.0).abs() < 0.01, "{before:?} {after:?}"); + assert!((after.tick_at(p.x) - tick).abs() < 2.0); + // A note drawn after zooming lands where the pointer is. + r.app.run("edit.tool", json!({"tool": "pencil"})).unwrap(); + let at = r.at(2400, 64); + r.click(at, Modifiers::NONE); + assert!(r.notes().contains(&(2400, 64, 240, 100)), "{:?}", r.notes()); +} From 7e0fddc30a8b6621823c5e9bd3697997b57eb497 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Tue, 13 Oct 2026 20:48:45 +0200 Subject: [PATCH 04/10] ROADMAP: MIDI Editor navigation and note gestures Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 731976509..b887b17c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ mutated and truncated movies. Field reports from the first public week are track - **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change. - **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change. - **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change. +- **2026-10-13 (MIDI Editor navigation and note gestures):** the MIDI Editor zooms (Ctrl/Cmd+wheel for time, Alt+wheel for row height) and scrolls, shows a bars ruler and the playhead, and draws grid, beat and bar lines on the session timeline. The Pencil draws a note as long as the drag and plays it, trimming one selected note trims them all, Alt-drag copies, edits keep the notes selected, and snapping follows the Edit mode (Ctrl/Cmd suspends it). The velocity lane edits only the selected notes (one note of a chord) and draws across stems. No readiness percentage change. - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ From 115496ffa5ecdfecdc503df41ba7a94d336dab20 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Mon, 19 Oct 2026 18:09:05 +0200 Subject: [PATCH 05/10] midi.notes_trim: an end trim past the start keeps the note where it is The start was clamped to the new end, so a note shrunk past its start moved earlier (a note at 960 trimmed 240 ticks from its end landed at 839), unlike the editor's preview. The start is now limited by the old end and the end by the new start. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/midi.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 3fc8314a5..6c6ea68a5 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -155,7 +155,8 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result { } /// Trim the start and/or end of several notes at once: `start` and `end` are tick offsets for the -/// note edges (a start trim keeps the note's end in place). Notes never shrink below one tick. +/// note edges (a start trim keeps the note's end in place, an end trim its start). Notes never +/// shrink below one tick. fn notes_trim(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.notes_trim")?; let idx = indices(p); @@ -165,9 +166,13 @@ fn notes_trim(e: &mut Engine, p: &Value) -> Result { let mut trimmed = Vec::with_capacity(idx.len()); for (i, n) in seq.notes.iter_mut().enumerate() { if idx.contains(&i) { - let end = n.end().saturating_add(de).max(1); - n.start = n.start.saturating_add(ds).clamp(0, end - 1); - n.length = (end - n.start).max(1); + // The start is limited by the old end and the end by the new start, so a trim past the + // other edge leaves a one-tick note there instead of moving it. + let old_end = n.end(); + let start = n.start.saturating_add(ds).clamp(0, (old_end - 1).max(0)); + let end = old_end.saturating_add(de).max(start + 1); + n.start = start; + n.length = end - start; trimmed.push(*n); } } @@ -269,7 +274,15 @@ mod tests { e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0, 1], "start": -600})).unwrap(); assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(0, 60, 1080), (360, 64, 960)]); e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [1], "start": 5000, "end": -5000})).unwrap(); - assert_eq!(starts_pitches_lengths(&mut e, clip)[1].2, 1); + assert_eq!(starts_pitches_lengths(&mut e, clip)[1], (1319, 64, 1)); + } + + #[test] + fn notes_trim_past_the_start_keeps_the_note_where_it_is() { + let mut e = Engine::default(); + let clip = keys_clip(&mut e, &[(60, 960, 120)]); + e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0], "end": -240})).unwrap(); + assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(960, 60, 1)], "like the editor's end-trim preview"); } #[test] From c7848212c95c1ec4c95591e44d7319e32e549ed5 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Wed, 21 Oct 2026 13:14:15 +0200 Subject: [PATCH 06/10] MIDI Editor: controller lanes per track and the Pencil's shapes Controller lanes sit under the Velocity lane: CC 0-127, pitch bend, mono aftertouch and program change, drawn as values held until the next event. Each MIDI track keeps its own lanes (saved with the session); a lane header menu adds, changes or removes them and marks the controllers the clip uses. In a lane the Pencil draws with the current shape (Free Hand, Line, Triangle, Square, Random, Parabolic, S-Curve; the periodic ones repeat at the grid), the Grabber adds, selects and drags events (Alt-drag copies, Alt-click deletes, rubber band on empty space), the Trimmer raises or lowers the selected events (or the whole lane), Delete removes them. In the Velocity lane the Pencil's shapes set the notes under the stroke. The shape is chosen from the Pencil's right-click menu or the MIDI Editor header. Commands: midi.ctrls, midi.ctrl_add, midi.ctrls_move, midi.ctrl_delete, midi.ctrls_draw (points or a shape), midi.lanes, edit.pencil_shape. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/midi.rs | 4 +- crates/engine/src/cmd/midi_ctrl.rs | 380 +++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/midi/src/ctrl.rs | 159 ++++++++ crates/midi/src/lib.rs | 7 +- crates/midi/src/sequence.rs | 2 +- crates/midi/src/shape.rs | 237 +++++++++++ crates/model/src/session.rs | 4 + crates/model/src/track.rs | 4 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/midi_editor.rs | 119 +++++- crates/ui-egui/src/midi_lanes.rs | 567 ++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 16 +- crates/ui-egui/tests/midi_cc_lanes.rs | 214 ++++++++++ docs/ui-parity.md | 5 +- 15 files changed, 1694 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/cmd/midi_ctrl.rs create mode 100644 crates/midi/src/ctrl.rs create mode 100644 crates/midi/src/shape.rs create mode 100644 crates/ui-egui/src/midi_lanes.rs create mode 100644 crates/ui-egui/tests/midi_cc_lanes.rs diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 6c6ea68a5..3a0d97db9 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { ] } -fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { +pub(super) fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) { Some(ClipContent::Midi { sequence }) => Ok(sequence), Some(_) => Err(bad(cmd, "not a MIDI clip")), @@ -109,7 +109,7 @@ fn note_edit(e: &mut Engine, p: &Value) -> Result { Ok(json!({"note": edited, "index": seq.notes.iter().position(|x| *x == edited)})) } -fn indices(p: &Value) -> Vec { +pub(super) fn indices(p: &Value) -> Vec { p.get("indices").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|x| x as usize).collect()).unwrap_or_default() } diff --git a/crates/engine/src/cmd/midi_ctrl.rs b/crates/engine/src/cmd/midi_ctrl.rs new file mode 100644 index 000000000..493ff9bf2 --- /dev/null +++ b/crates/engine/src/cmd/midi_ctrl.rs @@ -0,0 +1,380 @@ +//! MIDI controller editing: the MIDI Editor's controller lanes (CC, pitch bend, aftertouch, +//! program change), what the Pencil draws in them, and which lanes each track shows. + +use super::midi::{indices, seq_mut}; +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_midi::{CtrlEvent, CtrlKind, MAX_POINTS, PencilShape, Sequence, ShapeStroke}; +use soundcraft_model::ClipContent; +use soundcraft_time::TICKS_PER_QUARTER; +use std::collections::HashSet; + +/// How far apart a stroke's events are: a 64th note, about 30 ms at 120 BPM. +pub const DRAW_STEP: i64 = TICKS_PER_QUARTER / 16; + +pub fn specs() -> Vec { + vec![ + cmd!(query "midi.ctrls", "List MIDI Controllers", [], None, "{clip, kind?: cc0..cc127|pitchbend|aftertouch|program|polyat}", always, ctrls), + cmd!( + "midi.ctrl_add", + "Add Controller Event", + [], + None, + "{clip, kind, tick, value, channel?} (replaces the kind's event at that tick)", + always, + ctrl_add + ), + cmd!( + "midi.ctrls_move", + "Move Controller Events", + [], + None, + "{clip, indices: [n], ticks?: 0, value?: 0 (added to each value), copy?: false}", + always, + ctrls_move + ), + cmd!("midi.ctrl_delete", "Delete Controller Events", [], None, "{clip, indices: [n]}", always, ctrl_delete), + cmd!( + "midi.ctrls_draw", + "Draw Controller Events", + [], + None, + "{clip, kind, channel?, points: [[tick, value]] (Free Hand) | shape: line|triangle|square|random|parabolic|s_curve, from: [tick, value], to: [tick, value], period?: ticks (the grid; Triangle/Square/Random), step?: ticks (default a 64th), seed?} — replaces the kind's events between the first and last tick", + always, + ctrls_draw + ), + cmd!(noundo "midi.lanes", "MIDI Controller Lanes", [], None, "{track, lanes: [kind]} — the controller lanes the MIDI Editor shows for the track", always, lanes), + cmd!(noundo "edit.pencil_shape", "Pencil Shape", [], None, "{shape: free_hand|line|triangle|square|random|parabolic|s_curve}", always, pencil_shape), + ] +} + +fn kind_param(p: &Value, cmd: &str) -> Result { + str_param(p, "kind") + .and_then(CtrlKind::from_id) + .ok_or_else(|| bad(cmd, "`kind` must be cc0..cc127, pitchbend, aftertouch, program or polyat")) +} + +/// A value rounded into the kind's range (a non-number lands on the kind's resting value). +pub fn clamp_value(kind: CtrlKind, v: f64) -> i32 { + let (lo, hi) = kind.range(); + if v.is_finite() { v.round().clamp(f64::from(lo), f64::from(hi)) as i32 } else { kind.rest_value() } +} + +/// The channel new events of `kind` go on: that of the kind's events already there, else the +/// notes', else the first channel. +fn channel_for(seq: &Sequence, kind: CtrlKind, p: &Value) -> u8 { + match p.get("channel").and_then(Value::as_i64) { + Some(c) => c.clamp(0, 15) as u8, + None => seq.ctrls.iter().find(|c| c.kind == kind).map(|c| c.channel).or_else(|| seq.notes.first().map(|n| n.channel)).unwrap_or(0), + } +} + +fn event_json(i: usize, c: &CtrlEvent) -> Value { + json!({"index": i, "tick": c.tick, "channel": c.channel, "kind": c.kind.id(), "value": c.value}) +} + +fn ctrls(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.ctrls")?; + let kind = if p.get("kind").is_some() { Some(kind_param(p, "midi.ctrls")?) } else { None }; + match e.session().find_clip(id).map(|(_, c)| &c.content) { + Some(ClipContent::Midi { sequence }) => { + let list: Vec = + sequence.ctrls.iter().enumerate().filter(|(_, c)| kind.is_none_or(|k| c.kind == k)).map(|(i, c)| event_json(i, c)).collect(); + Ok(json!({"ctrls": list})) + } + Some(_) => Err(bad("midi.ctrls", "not a MIDI clip")), + None => Err(bad("midi.ctrls", "no such clip")), + } +} + +/// Puts `new` into the sequence, each replacing any event of the same kind and channel at its +/// tick (among `new` the last one wins), and returns where they landed after sorting. +fn insert(seq: &mut Sequence, new: Vec) -> Vec { + let mut keys = HashSet::with_capacity(new.len()); + let mut unique: Vec = Vec::with_capacity(new.len()); + for c in new.into_iter().rev() { + if keys.insert((c.tick, c.channel, c.kind)) { + unique.push(c); + } + } + seq.ctrls.retain(|c| !keys.contains(&(c.tick, c.channel, c.kind))); + seq.ctrls.extend(unique); + seq.sort(); + seq.ctrls.iter().enumerate().filter(|(_, c)| keys.contains(&(c.tick, c.channel, c.kind))).map(|(i, _)| i).collect() +} + +fn ctrl_add(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.ctrl_add")?; + let kind = kind_param(p, "midi.ctrl_add")?; + let tick = i64_or(p, "tick", 0).max(0); + let value = clamp_value(kind, f64_or(p, "value", f64::from(kind.rest_value()))); + let seq = seq_mut(e, id, "midi.ctrl_add")?; + let channel = channel_for(seq, kind, p); + let at = insert(seq, vec![CtrlEvent { tick, channel, kind, value }]); + Ok(json!({"index": at.first(), "ctrls": seq.ctrls.len()})) +} + +fn ctrls_move(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.ctrls_move")?; + let idx: HashSet = indices(p).into_iter().collect(); + let dt = i64_or(p, "ticks", 0); + let dv = i64_or(p, "value", 0); + let copy = bool_or(p, "copy", false); + let seq = seq_mut(e, id, "midi.ctrls_move")?; + let mut moved = Vec::with_capacity(idx.len()); + for (i, c) in seq.ctrls.iter().enumerate() { + if idx.contains(&i) { + let (lo, hi) = c.kind.range(); + let value = i64::from(c.value).saturating_add(dv).clamp(i64::from(lo), i64::from(hi)) as i32; + moved.push(CtrlEvent { tick: c.tick.saturating_add(dt).max(0), value, ..*c }); + } + } + if !copy { + let mut i = 0; + seq.ctrls.retain(|_| { + let keep = !idx.contains(&i); + i += 1; + keep + }); + } + let count = moved.len(); + Ok(json!({"moved": count, "indices": insert(seq, moved)})) +} + +fn ctrl_delete(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "midi.ctrl_delete")?; + let idx: HashSet = indices(p).into_iter().collect(); + let seq = seq_mut(e, id, "midi.ctrl_delete")?; + let before = seq.ctrls.len(); + let mut i = 0; + seq.ctrls.retain(|_| { + let keep = !idx.contains(&i); + i += 1; + keep + }); + Ok(json!({"deleted": before - seq.ctrls.len()})) +} + +/// `[tick, value]` from a two-element array. +fn tick_value(v: &Value) -> Option<(i64, f64)> { + let a = v.as_array()?; + Some((a.first()?.as_i64()?, a.get(1)?.as_f64()?)) +} + +/// The stroke a `midi.ctrls_draw` call describes, as sorted `(tick, value)` points with one per tick. +pub fn stroke_points(p: &Value, cmd: &str) -> Result> { + let mut pts: Vec<(i64, f64)> = if let Some(arr) = p.get("points").and_then(Value::as_array) { + arr.iter().take(MAX_POINTS as usize).filter_map(tick_value).collect() + } else { + let shape = match str_param(p, "shape") { + Some(s) => PencilShape::from_id(s).ok_or_else(|| bad(cmd, format!("unknown shape `{s}`")))?, + None => PencilShape::Line, + }; + let (Some(from), Some(to)) = (p.get("from").and_then(tick_value), p.get("to").and_then(tick_value)) else { + return Err(bad(cmd, "`points`, or `from` and `to` as [tick, value], required")); + }; + let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0); + ShapeStroke { shape, from, to, period: i64_or(p, "period", TICKS_PER_QUARTER / 4), seed }.points(i64_or(p, "step", DRAW_STEP)) + }; + for pt in &mut pts { + pt.0 = pt.0.max(0); + } + pts.sort_by_key(|pt| pt.0); + // Several points on one tick: the last one drawn wins. + pts.dedup_by(|later, kept| { + let same = later.0 == kept.0; + if same { + *kept = *later; + } + same + }); + if pts.is_empty() { + return Err(bad(cmd, "no points to draw")); + } + Ok(pts) +} + +/// Replaces the kind's events between the stroke's first and last tick (on every channel, as the +/// lane shows them all) with the stroke. Points that repeat the value before them are left out: +/// a controller holds its value until the next event. +fn ctrls_draw(e: &mut Engine, p: &Value) -> Result { + const CMD: &str = "midi.ctrls_draw"; + let id = clip_id(p, CMD)?; + let kind = kind_param(p, CMD)?; + let pts = stroke_points(p, CMD)?; + let (lo, hi) = (pts.first().map_or(0, |pt| pt.0), pts.last().map_or(0, |pt| pt.0)); + let seq = seq_mut(e, id, CMD)?; + let channel = channel_for(seq, kind, p); + seq.ctrls.retain(|c| c.kind != kind || c.tick < lo || c.tick > hi); + let mut new = Vec::with_capacity(pts.len()); + let mut last = None; + for (tick, v) in pts { + let value = clamp_value(kind, v); + if last != Some(value) { + new.push(CtrlEvent { tick, channel, kind, value }); + last = Some(value); + } + } + let count = new.len(); + Ok(json!({"drawn": count, "start": lo, "end": hi, "indices": insert(seq, new)})) +} + +fn lanes(e: &mut Engine, p: &Value) -> Result { + const CMD: &str = "midi.lanes"; + let t = track_param(e, CMD, p, "track")?.ok_or_else(|| bad(CMD, "`track` required"))?; + let arr = p.get("lanes").and_then(Value::as_array).ok_or_else(|| bad(CMD, "`lanes` must be a list of controller kinds"))?; + let mut lanes: Vec = Vec::new(); + for v in arr.iter().take(128) { + let k = v.as_str().and_then(CtrlKind::from_id).ok_or_else(|| bad(CMD, format!("unknown controller {v}")))?; + if !lanes.contains(&k) { + lanes.push(k); + } + } + let ids: Vec = lanes.iter().map(|k| k.id()).collect(); + match e.session().track(t) { + Some(track) if !track.kind.is_midi() => return Err(bad(CMD, "not a MIDI or instrument track")), + Some(track) if track.midi_lanes == lanes => return Ok(json!({"lanes": ids})), + _ => {} + } + let track = e.session_mut().track_mut(t).ok_or_else(|| bad(CMD, "no such track"))?; + track.midi_lanes = lanes; + Ok(json!({"lanes": ids})) +} + +fn pencil_shape(e: &mut Engine, p: &Value) -> Result { + let shape = str_param(p, "shape").and_then(PencilShape::from_id).ok_or_else(|| bad("edit.pencil_shape", "unknown shape"))?; + if e.session().edit.pencil_shape != shape { + e.session_mut().edit.pencil_shape = shape; + } + Ok(json!({"shape": shape.id()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_model::{ChannelFormat, TrackKind}; + + fn clip(e: &mut Engine) -> (u64, u64) { + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap(); + (t.0, clip) + } + + fn events(e: &mut Engine, clip: u64, kind: &str) -> Vec<(i64, i64)> { + let r = e.execute("midi.ctrls", &json!({"clip": clip, "kind": kind})).unwrap(); + r["ctrls"].as_array().unwrap().iter().map(|c| (c["tick"].as_i64().unwrap(), c["value"].as_i64().unwrap())).collect() + } + + #[test] + fn add_replaces_the_event_at_its_tick_and_values_stay_in_range() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc1", "tick": 480, "value": 20})).unwrap(); + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc1", "tick": 0, "value": 300})).unwrap(); + let r = e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc1", "tick": 480, "value": 90})).unwrap(); + assert_eq!(r["index"], 1); + assert_eq!(events(&mut e, c, "cc1"), vec![(0, 127), (480, 90)]); + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "pitchbend", "tick": 10, "value": -9000})).unwrap(); + assert_eq!(events(&mut e, c, "pitchbend"), vec![(10, -8192)]); + assert!(e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc200", "tick": 0})).is_err()); + } + + #[test] + fn move_shifts_time_and_value_and_reports_the_new_indices() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + for (tick, value) in [(0, 10), (480, 20), (960, 30)] { + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc7", "tick": tick, "value": value})).unwrap(); + } + let r = e.execute("midi.ctrls_move", &json!({"clip": c, "indices": [0], "ticks": 720, "value": 5})).unwrap(); + assert_eq!(r["indices"], json!([1])); + assert_eq!(events(&mut e, c, "cc7"), vec![(480, 20), (720, 15), (960, 30)]); + // Landing on another event's tick replaces it; a copy leaves the original. + e.execute("midi.ctrls_move", &json!({"clip": c, "indices": [0], "ticks": 480, "copy": true})).unwrap(); + assert_eq!(events(&mut e, c, "cc7"), vec![(480, 20), (720, 15), (960, 20)]); + e.execute("midi.ctrls_move", &json!({"clip": c, "indices": [0, 1, 2], "ticks": i64::MIN, "value": i64::MAX})).unwrap(); + assert_eq!(events(&mut e, c, "cc7"), vec![(0, 127)], "all three land on tick 0: one event remains"); + } + + #[test] + fn a_line_replaces_only_its_span_and_skips_repeated_values() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + for tick in [0, 600, 2000] { + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc1", "tick": tick, "value": 5})).unwrap(); + } + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc7", "tick": 600, "value": 100})).unwrap(); + let r = + e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "cc1", "shape": "line", "from": [480, 0], "to": [960, 8], "step": 60})).unwrap(); + assert_eq!((r["start"].as_i64(), r["end"].as_i64()), (Some(480), Some(960))); + assert_eq!( + events(&mut e, c, "cc1"), + vec![(0, 5), (480, 0), (540, 1), (600, 2), (660, 3), (720, 4), (780, 5), (840, 6), (900, 7), (960, 8), (2000, 5)] + ); + assert_eq!(events(&mut e, c, "cc7"), vec![(600, 100)], "other controllers are untouched"); + let flat = e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "cc1", "from": [0, 64], "to": [2000, 64]})).unwrap(); + assert_eq!(flat["drawn"], 1); + assert_eq!(events(&mut e, c, "cc1"), vec![(0, 64)]); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(events(&mut e, c, "cc1").len(), 11, "one undo step per stroke"); + } + + #[test] + fn free_hand_points_are_sorted_and_one_per_tick() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "aftertouch", "points": [[300, 40], [100, 10], [200, 20], [200, 30], [-5, 1]]})) + .unwrap(); + assert_eq!(events(&mut e, c, "aftertouch"), vec![(0, 1), (100, 10), (200, 30), (300, 40)]); + assert!(e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "cc1", "points": []})).is_err()); + assert!(e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "cc1", "shape": "spiral", "from": [0, 0], "to": [1, 1]})).is_err()); + } + + #[test] + fn shapes_draw_at_the_grid() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + e.execute("midi.ctrls_draw", &json!({"clip": c, "kind": "cc11", "shape": "square", "from": [0, 0], "to": [960, 127], "period": 480})) + .unwrap(); + assert_eq!(events(&mut e, c, "cc11"), vec![(0, 0), (240, 127), (480, 0), (720, 127), (960, 0)]); + let huge = json!({"clip": c, "kind": "cc11", "shape": "triangle", "from": [0, 0], "to": [i64::MAX, 127], "period": 1, "step": 1}); + let r = e.execute("midi.ctrls_draw", &huge).unwrap(); + assert!(r["drawn"].as_i64().unwrap() <= 2 * MAX_POINTS + 1); + } + + #[test] + fn delete_removes_the_listed_events() { + let mut e = Engine::default(); + let (_, c) = clip(&mut e); + for tick in [0, 480, 960] { + e.execute("midi.ctrl_add", &json!({"clip": c, "kind": "cc64", "tick": tick, "value": 127})).unwrap(); + } + let r = e.execute("midi.ctrl_delete", &json!({"clip": c, "indices": [0, 2, 99]})).unwrap(); + assert_eq!(r["deleted"], 2); + assert_eq!(events(&mut e, c, "cc64"), vec![(480, 127)]); + } + + #[test] + fn lanes_belong_to_midi_tracks_and_are_not_an_undo_step() { + let mut e = Engine::default(); + let (t, _) = clip(&mut e); + let undo_before = e.undo_history(); + let r = e.execute("midi.lanes", &json!({"track": t, "lanes": ["cc1", "PB", "cc1", "cc 64"]})).unwrap(); + assert_eq!(r["lanes"], json!(["cc1", "pitchbend", "cc64"])); + assert_eq!(e.session().track(TrackId(t)).unwrap().midi_lanes, vec![CtrlKind::Cc(1), CtrlKind::PitchBend, CtrlKind::Cc(64)]); + assert_eq!(e.undo_history(), undo_before); + assert!(e.execute("midi.lanes", &json!({"track": t, "lanes": ["volume knob"]})).is_err()); + let audio = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None); + assert!(e.execute("midi.lanes", &json!({"track": audio.0, "lanes": ["cc1"]})).is_err()); + } + + #[test] + fn pencil_shape_is_an_edit_setting() { + let mut e = Engine::default(); + assert_eq!(e.session().edit.pencil_shape, PencilShape::FreeHand); + e.execute("edit.pencil_shape", &json!({"shape": "s-curve"})).unwrap(); + assert_eq!(e.session().edit.pencil_shape, PencilShape::SCurve); + assert!(e.execute("edit.pencil_shape", &json!({"shape": "zigzag"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b6f379bd8..ee7af9872 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,6 +20,7 @@ mod file; mod health; mod instrument; mod midi; +mod midi_ctrl; mod mix; mod more_util; mod options; @@ -122,6 +123,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(audiosuite::specs()); v.extend(audio_midi::specs()); v.extend(midi::specs()); + v.extend(midi_ctrl::specs()); v.extend(view_more::specs()); v.extend(track_more::specs()); v.extend(clip_more::specs()); diff --git a/crates/midi/src/ctrl.rs b/crates/midi/src/ctrl.rs new file mode 100644 index 000000000..75813790f --- /dev/null +++ b/crates/midi/src/ctrl.rs @@ -0,0 +1,159 @@ +//! Controller kinds as the editor and the commands name them: ids, value ranges and display names. + +use crate::{CtrlKind, note_name}; + +/// Standard names of the MIDI 1.0 controller numbers people draw most. +pub fn cc_name(n: u8) -> Option<&'static str> { + Some(match n { + 0 => "Bank Select", + 1 => "Mod Wheel", + 2 => "Breath", + 4 => "Foot Pedal", + 5 => "Portamento Time", + 6 => "Data Entry", + 7 => "Volume", + 8 => "Balance", + 10 => "Pan", + 11 => "Expression", + 12 => "Effect 1", + 13 => "Effect 2", + 64 => "Sustain", + 65 => "Portamento", + 66 => "Sostenuto", + 67 => "Soft Pedal", + 68 => "Legato", + 69 => "Hold 2", + 71 => "Resonance", + 72 => "Release Time", + 73 => "Attack Time", + 74 => "Cutoff", + 84 => "Portamento Control", + 91 => "Reverb", + 92 => "Tremolo", + 93 => "Chorus", + 94 => "Detune", + 95 => "Phaser", + 120 => "All Sound Off", + 121 => "Reset Controllers", + 123 => "All Notes Off", + _ => return None, + }) +} + +impl CtrlKind { + /// The kinds offered first when a controller lane is chosen. + pub const COMMON: [CtrlKind; 9] = [ + CtrlKind::Cc(1), + CtrlKind::Cc(2), + CtrlKind::Cc(7), + CtrlKind::Cc(10), + CtrlKind::Cc(11), + CtrlKind::Cc(64), + CtrlKind::PitchBend, + CtrlKind::ChannelPressure, + CtrlKind::Program, + ]; + + /// A short id for commands and saved lanes: `cc7`, `pitchbend`, `aftertouch`, `program`, `polyat60`. + pub fn id(self) -> String { + match self { + CtrlKind::Cc(n) => format!("cc{n}"), + CtrlKind::PitchBend => "pitchbend".into(), + CtrlKind::ChannelPressure => "aftertouch".into(), + CtrlKind::Program => "program".into(), + CtrlKind::PolyPressure(p) => format!("polyat{p}"), + } + } + + /// Parses [`CtrlKind::id`] and a few spellings people type (`cc:7`, `CC 7`, `pitch_bend`, `pb`). + pub fn from_id(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + let number = |rest: &str| rest.trim_start_matches([':', ' ', '_']).parse::().ok().filter(|n| *n <= 127); + match s.as_str() { + "pitchbend" | "pitch_bend" | "pitch bend" | "bend" | "pb" => Some(CtrlKind::PitchBend), + "aftertouch" | "channel_pressure" | "pressure" | "mono_aftertouch" | "at" => Some(CtrlKind::ChannelPressure), + "program" | "program_change" | "pc" => Some(CtrlKind::Program), + _ => { + if let Some(rest) = s.strip_prefix("polyat").or_else(|| s.strip_prefix("poly_pressure")) { + number(rest).map(CtrlKind::PolyPressure) + } else { + s.strip_prefix("cc").and_then(number).map(CtrlKind::Cc) + } + } + } + } + + /// Smallest and largest value. + pub fn range(self) -> (i32, i32) { + match self { + CtrlKind::PitchBend => (-8192, 8191), + _ => (0, 127), + } + } + + /// The value an untouched controller sits at: centre for pitch bend and pan, else 0. + pub fn rest_value(self) -> i32 { + match self { + CtrlKind::PitchBend => 0, + CtrlKind::Cc(10) | CtrlKind::Cc(8) => 64, + _ => 0, + } + } + + /// The full name, e.g. "Mod Wheel (CC 1)", "Pitch Bend". + pub fn label(self) -> String { + match self { + CtrlKind::Cc(n) => match cc_name(n) { + Some(name) => format!("{name} (CC {n})"), + None => format!("CC {n}"), + }, + CtrlKind::PitchBend => "Pitch Bend".into(), + CtrlKind::ChannelPressure => "Mono Aftertouch".into(), + CtrlKind::Program => "Program Change".into(), + CtrlKind::PolyPressure(p) => format!("Poly Aftertouch {}", note_name(p)), + } + } + + /// A name that fits a narrow lane header. + pub fn short(self) -> String { + match self { + CtrlKind::Cc(1) => "Mod".into(), + CtrlKind::Cc(2) => "Breath".into(), + CtrlKind::Cc(7) => "Volume".into(), + CtrlKind::Cc(10) => "Pan".into(), + CtrlKind::Cc(11) => "Expr".into(), + CtrlKind::Cc(64) => "Sustain".into(), + CtrlKind::Cc(n) => format!("CC {n}"), + CtrlKind::PitchBend => "Bend".into(), + CtrlKind::ChannelPressure => "Aftert.".into(), + CtrlKind::Program => "Program".into(), + CtrlKind::PolyPressure(p) => format!("PolyAT {}", note_name(p)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_round_trip_and_common_spellings_parse() { + for k in [CtrlKind::Cc(0), CtrlKind::Cc(127), CtrlKind::PitchBend, CtrlKind::ChannelPressure, CtrlKind::Program, CtrlKind::PolyPressure(60)] { + assert_eq!(CtrlKind::from_id(&k.id()), Some(k), "{k:?}"); + } + assert_eq!(CtrlKind::from_id("CC 7"), Some(CtrlKind::Cc(7))); + assert_eq!(CtrlKind::from_id("cc:64"), Some(CtrlKind::Cc(64))); + assert_eq!(CtrlKind::from_id("pitch_bend"), Some(CtrlKind::PitchBend)); + assert_eq!(CtrlKind::from_id("cc128"), None); + assert_eq!(CtrlKind::from_id("cc"), None); + assert_eq!(CtrlKind::from_id("nonsense"), None); + } + + #[test] + fn names_and_ranges() { + assert_eq!(CtrlKind::Cc(1).label(), "Mod Wheel (CC 1)"); + assert_eq!(CtrlKind::Cc(3).label(), "CC 3"); + assert_eq!(CtrlKind::PitchBend.range(), (-8192, 8191)); + assert_eq!(CtrlKind::Cc(10).rest_value(), 64); + } +} diff --git a/crates/midi/src/lib.rs b/crates/midi/src/lib.rs index 5ecb684cc..3f899713d 100644 --- a/crates/midi/src/lib.rs +++ b/crates/midi/src/lib.rs @@ -6,18 +6,23 @@ //! export (format 1, 960 PPQ). The reader treats its input as hostile: every length is checked, //! variable-length quantities are bounded and event counts are capped. //! - [`ops`]: MIDI editing operations (quantize, transpose, velocity, duration, split). -//! - [`note_name`], [`gm_drum_name`], [`gm_program_name`]: display names. +//! - [`note_name`], [`gm_drum_name`], [`gm_program_name`], [`cc_name`]: display names. +//! - [`PencilShape`] / [`ShapeStroke`]: what a Pencil stroke draws, as values over ticks. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod ctrl; mod names; pub mod ops; mod rng; mod sequence; +mod shape; mod smf; +pub use ctrl::cc_name; pub use names::{gm_drum_name, gm_program_name, note_name}; pub use rng::XorShift; pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence}; +pub use shape::{MAX_POINTS, PencilShape, ShapeStroke}; pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf}; /// Ticks per quarter note used throughout the model. diff --git a/crates/midi/src/sequence.rs b/crates/midi/src/sequence.rs index e50c6614d..07385040d 100644 --- a/crates/midi/src/sequence.rs +++ b/crates/midi/src/sequence.rs @@ -25,7 +25,7 @@ impl Note { } /// Kind of a continuous / channel controller event. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum CtrlKind { /// Control change with the controller number 0..=127. Value 0..=127. Cc(u8), diff --git a/crates/midi/src/shape.rs b/crates/midi/src/shape.rs new file mode 100644 index 000000000..2c9c66cfb --- /dev/null +++ b/crates/midi/src/shape.rs @@ -0,0 +1,237 @@ +//! Pencil shapes: what a Pencil stroke from a press point to the pointer draws, as values over ticks. +//! +//! Line, Parabolic and S-Curve run from one end of the stroke to the other. Triangle, Square and +//! Random repeat every `period` ticks (the grid), swinging between the value where the stroke +//! started and the value under the pointer. Free Hand follows the pointer, so its points come from +//! the editor; here it draws like Line. + +use crate::XorShift; +use serde::{Deserialize, Serialize}; + +/// Most points one stroke produces, however long it is or however fine its step. +pub const MAX_POINTS: i64 = 10_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PencilShape { + #[default] + FreeHand, + Line, + Triangle, + Square, + Random, + Parabolic, + SCurve, +} + +impl PencilShape { + pub const ALL: [PencilShape; 7] = [ + PencilShape::FreeHand, + PencilShape::Line, + PencilShape::Triangle, + PencilShape::Square, + PencilShape::Random, + PencilShape::Parabolic, + PencilShape::SCurve, + ]; + + pub fn id(self) -> &'static str { + match self { + PencilShape::FreeHand => "free_hand", + PencilShape::Line => "line", + PencilShape::Triangle => "triangle", + PencilShape::Square => "square", + PencilShape::Random => "random", + PencilShape::Parabolic => "parabolic", + PencilShape::SCurve => "s_curve", + } + } + + pub fn label(self) -> &'static str { + match self { + PencilShape::FreeHand => "Free Hand", + PencilShape::Line => "Line", + PencilShape::Triangle => "Triangle", + PencilShape::Square => "Square", + PencilShape::Random => "Random", + PencilShape::Parabolic => "Parabolic", + PencilShape::SCurve => "S-Curve", + } + } + + pub fn from_id(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase().replace(['-', ' '], "_"); + match s.as_str() { + "freehand" => Some(PencilShape::FreeHand), + "scurve" => Some(PencilShape::SCurve), + _ => PencilShape::ALL.into_iter().find(|p| p.id() == s), + } + } + + /// Whether the shape repeats at the grid. + pub fn periodic(self) -> bool { + matches!(self, PencilShape::Triangle | PencilShape::Square | PencilShape::Random) + } +} + +/// One stroke: from the press point `from` to the pointer `to`, each `(tick, value)`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShapeStroke { + pub shape: PencilShape, + pub from: (i64, f64), + pub to: (i64, f64), + /// The repeat length of Triangle, Square and Random, in ticks (the grid). + pub period: i64, + /// Seeds Random, so the same stroke draws the same values. + pub seed: u64, +} + +impl ShapeStroke { + /// The first and last tick the stroke covers. + pub fn span(&self) -> (i64, i64) { + (self.from.0.min(self.to.0), self.from.0.max(self.to.0)) + } + + /// The period in use: even (so a Square flips on a tick) and long enough to stay within + /// [`MAX_POINTS`] corners. + fn period(&self) -> i64 { + let (lo, hi) = self.span(); + let floor = hi.saturating_sub(lo) / (MAX_POINTS / 2) + 1; + (self.period.max(floor).max(2) / 2).saturating_mul(2) + } + + /// The value the stroke draws at `tick` (clamped to the span). + pub fn value_at(&self, tick: i64) -> f64 { + let (lo, hi) = self.span(); + let tick = tick.clamp(lo, hi); + let (a, b) = (self.from.1, self.to.1); + let p = self.period(); + let phase = || tick.saturating_sub(lo).rem_euclid(p) as f64 / p as f64; + match self.shape { + PencilShape::FreeHand | PencilShape::Line | PencilShape::Parabolic | PencilShape::SCurve => { + // From the stroke's left end to its right end, whichever way it was drawn. + let (l, r) = if self.from.0 <= self.to.0 { (a, b) } else { (b, a) }; + let x = if hi > lo { (tick.saturating_sub(lo)) as f64 / (hi.saturating_sub(lo)) as f64 } else { 1.0 }; + let f = match self.shape { + PencilShape::Parabolic => x * x, + PencilShape::SCurve => x * x * (3.0 - 2.0 * x), + _ => x, + }; + l + (r - l) * f + } + PencilShape::Triangle => a + (b - a) * (1.0 - (2.0 * phase() - 1.0).abs()), + PencilShape::Square => { + if phase() < 0.5 { + a + } else { + b + } + } + PencilShape::Random => { + let k = tick.saturating_sub(lo) / p; + let mut rng = XorShift::new(self.seed ^ (k as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)); + a + (b - a) * (rng.next_signed() + 1.0) / 2.0 + } + } + } + + /// The ticks to put events at. Square and Random change only at their steps, so they get one + /// event per change; the others get one every `step` ticks plus their ends and corners. + pub fn ticks(&self, step: i64) -> Vec { + let (lo, hi) = self.span(); + let len = hi.saturating_sub(lo); + let p = self.period(); + let every = |by: i64| (0..MAX_POINTS).map(move |k| lo.saturating_add(k.saturating_mul(by))).take_while(move |t| *t <= hi); + let mut out: Vec = match self.shape { + PencilShape::Square => every(p / 2).collect(), + PencilShape::Random => every(p).collect(), + _ => { + let step = step.max(1).max(len / MAX_POINTS + 1); + let mut v: Vec = every(step).collect(); + if self.shape == PencilShape::Triangle { + v.extend(every(p / 2)); + } + v.push(hi); + v + } + }; + out.sort_unstable(); + out.dedup(); + out + } + + /// `(tick, value)` for each of [`ShapeStroke::ticks`]. + pub fn points(&self, step: i64) -> Vec<(i64, f64)> { + self.ticks(step).into_iter().map(|t| (t, self.value_at(t))).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stroke(shape: PencilShape, from: (i64, f64), to: (i64, f64)) -> ShapeStroke { + ShapeStroke { shape, from, to, period: 480, seed: 7 } + } + + #[test] + fn ids_round_trip() { + for s in PencilShape::ALL { + assert_eq!(PencilShape::from_id(s.id()), Some(s)); + } + assert_eq!(PencilShape::from_id("S-Curve"), Some(PencilShape::SCurve)); + assert_eq!(PencilShape::from_id("freehand"), Some(PencilShape::FreeHand)); + assert_eq!(PencilShape::from_id("spiral"), None); + } + + #[test] + fn line_runs_end_to_end_whichever_way_it_was_drawn() { + let right = stroke(PencilShape::Line, (0, 0.0), (960, 100.0)).points(240); + assert_eq!(right, vec![(0, 0.0), (240, 25.0), (480, 50.0), (720, 75.0), (960, 100.0)]); + let left = stroke(PencilShape::Line, (960, 100.0), (0, 0.0)).points(240); + assert_eq!(left, right); + } + + #[test] + fn curves_start_flat_and_end_at_the_pointer() { + let p = stroke(PencilShape::Parabolic, (0, 0.0), (1000, 100.0)); + assert_eq!(p.value_at(500), 25.0); + let s = stroke(PencilShape::SCurve, (0, 0.0), (1000, 100.0)); + assert_eq!(s.value_at(500), 50.0); + assert!(s.value_at(100) < 10.0 && s.value_at(900) > 90.0); + assert_eq!(s.value_at(1000), 100.0); + } + + #[test] + fn triangle_swings_to_the_pointer_value_once_per_period() { + let t = stroke(PencilShape::Triangle, (0, 10.0), (960, 90.0)); + assert_eq!(t.value_at(0), 10.0); + assert_eq!(t.value_at(240), 90.0); + assert_eq!(t.value_at(480), 10.0); + // The peaks are events even when the step misses them. + assert!(t.ticks(100).contains(&240) && t.ticks(100).contains(&720)); + } + + #[test] + fn square_and_random_change_only_at_their_steps() { + let sq = stroke(PencilShape::Square, (0, 0.0), (960, 127.0)); + assert_eq!(sq.points(10), vec![(0, 0.0), (240, 127.0), (480, 0.0), (720, 127.0), (960, 0.0)]); + let r = stroke(PencilShape::Random, (0, 20.0), (960, 60.0)); + assert_eq!(r.ticks(10), vec![0, 480, 960]); + for (_, v) in r.points(10) { + assert!((20.0..=60.0).contains(&v), "{v}"); + } + assert_eq!(r.points(10), r.points(10), "the same seed draws the same values"); + } + + #[test] + fn hostile_spans_stay_bounded() { + let huge = ShapeStroke { shape: PencilShape::Triangle, from: (i64::MIN, 0.0), to: (i64::MAX, 1.0), period: 1, seed: 0 }; + assert!(huge.points(1).len() as i64 <= 2 * MAX_POINTS + 1); + let sq = ShapeStroke { shape: PencilShape::Square, period: 0, ..huge }; + assert!(sq.points(0).len() as i64 <= MAX_POINTS); + assert!(sq.value_at(0).is_finite()); + let point = stroke(PencilShape::Line, (5, 1.0), (5, 9.0)); + assert_eq!(point.points(60), vec![(5, 9.0)]); + } +} diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index b72818dc2..73fc164b1 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -221,6 +221,9 @@ pub struct EditState { /// Numeric settings keyed by id (e.g. `click.volume_db`, `engine.buffer_size`). #[serde(default)] pub values: std::collections::BTreeMap, + /// What the Pencil draws (Free Hand, Line, Triangle, Square, Random, Parabolic, S-Curve). + #[serde(default)] + pub pencil_shape: soundcraft_midi::PencilShape, } impl EditState { @@ -291,6 +294,7 @@ impl Default for EditState { .map(|s| s.to_string()) .collect(), values: std::collections::BTreeMap::new(), + pencil_shape: soundcraft_midi::PencilShape::FreeHand, } } } diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index 9df78c353..9eb085f54 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -244,6 +244,9 @@ pub struct Track { /// Pinned to top in the Edit window. #[serde(default)] pub pinned: bool, + /// The controller lanes the MIDI Editor shows under the Velocity lane, top to bottom. + #[serde(default)] + pub midi_lanes: Vec, } fn yes() -> bool { @@ -283,6 +286,7 @@ impl Track { ticks_timebase: kind.is_midi(), elastic: None, pinned: false, + midi_lanes: Vec::new(), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 657641a15..61b4603f9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod fonts; pub mod icons; pub mod menus; pub mod midi_editor; +pub mod midi_lanes; pub mod mix_window; pub mod ops_windows; pub mod palette; diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index c00e49648..a9864db79 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -1,6 +1,8 @@ -//! The MIDI Editor: a piano roll with a ruler and a velocity lane, docked under the Edit window. +//! The MIDI Editor: a piano roll with a ruler, a velocity lane and the track's controller lanes, +//! docked under the Edit window. use crate::SoundApp; +use crate::midi_lanes::{LANE_GAP, LaneCtx, LaneGesture, LaneView, lane_heights}; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -10,7 +12,6 @@ use soundcraft_time::{TICKS_PER_QUARTER, TempoMap}; const KEY_W: f32 = 48.0; const RULER_H: f32 = 18.0; -const VEL_H: f32 = 64.0; /// Default, smallest and largest key row height (the vertical zoom). const ROW_H: f32 = 9.0; const ROW_MIN: f32 = 4.0; @@ -44,6 +45,13 @@ pub struct MidiEditorState { pub vel_last: Option, /// Where everything was drawn on the last frame. pub view: Option, + /// Selected controller events (indices into the clip's controller list). Notes and controller + /// events are never selected together. + pub ctrl_selected: Vec, + /// A gesture in a controller lane (or a Pencil shape in the velocity lane). + pub lane_gesture: Option, + /// The controller lanes drawn on the last frame. + pub lanes: Vec, } impl MidiEditorState { @@ -56,6 +64,8 @@ impl MidiEditorState { self.band = None; self.draft = None; self.vel_last = None; + self.ctrl_selected.clear(); + self.lane_gesture = None; self.px_per_tick = 0.0; self.view_tick = 0.0; } @@ -156,7 +166,7 @@ fn edge_kind(r: Rect, x: f32) -> DragKind { /// The nearest grid line to a clip-relative tick. Lines are counted from the session start, so /// notes land on the bars and beats of the timeline wherever the clip starts. -fn snap_tick(tick: f64, base: i64, grid: i64) -> i64 { +pub(crate) fn snap_tick(tick: f64, base: i64, grid: i64) -> i64 { let g = grid.max(1) as f64; let abs = tick + base as f64; if !abs.is_finite() { @@ -189,7 +199,7 @@ fn length_label(map: &TempoMap, at: i64, len: i64) -> String { } /// A position as bars|beats|ticks. -fn position_label(map: &TempoMap, at: i64) -> String { +pub(crate) fn position_label(map: &TempoMap, at: i64) -> String { let bb = map.bar_beat_at_tick(at); format!("{}|{}|{:03}", bb.bar, bb.beat, bb.tick) } @@ -235,11 +245,33 @@ fn audition(app: &mut SoundApp, want: Option<(TrackId, u8, u8)>) { app.midi.audition = want.map(|(t, p, _)| (t, p)); } -/// Indices returned by a note command, for keeping the touched notes selected. -fn returned_indices(r: Result) -> Vec { +/// Indices returned by a note or controller command, for keeping the touched events selected. +pub(crate) fn returned_indices(r: Result) -> Vec { r.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default() } +/// A one-line readout next to the pointer. +pub(crate) fn tooltip(ui: &Ui, full: Rect, p: Pos2, text: String) { + let t = Tokens::current(); + let tip = ui.painter().with_clip_rect(full); + let galley = tip.layout_no_wrap(text, regular(10.5), t.text); + let at = pos2((p.x + 14.0).min(full.max.x - galley.size().x - 6.0), (p.y - 20.0).max(full.min.y + 2.0)); + tip.rect_filled(Rect::from_min_size(at, galley.size()).expand(3.0), 3.0, t.panel_bg2.gamma_multiply(0.95)); + tip.galley(at, galley, t.text); +} + +/// The Pencil's shape, also on the Pencil tool's right-click menu in the toolbar. +fn pencil_shape_combo(app: &mut SoundApp, ui: &mut Ui) { + let cur = app.engine.session().edit.pencil_shape; + egui::ComboBox::from_id_salt("midi_pencil_shape").selected_text(format!("Pencil: {}", cur.label())).width(118.0).show_ui(ui, |ui| { + for s in soundcraft_midi::PencilShape::ALL { + if ui.selectable_label(cur == s, s.label()).clicked() { + let _ = app.run("edit.pencil_shape", json!({"shape": s.id()})); + } + } + }); +} + pub fn show(app: &mut SoundApp, ui: &mut Ui) { if !ui.input(|i| i.pointer.primary_down()) { audition(app, None); @@ -305,7 +337,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { _ => TICKS_PER_QUARTER / 4, }; // Header toolbar: whole-clip MIDI operations. - let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); + let bar = Rect::from_min_max(pos2(header.max.x - 450.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); tb.spacing_mut().item_spacing.x = 4.0; let mut op: Option<(&str, serde_json::Value)> = None; @@ -321,6 +353,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); } + pencil_shape_combo(app, &mut tb); if let Some((id, mut p)) = op { p["clips"] = json!([cid.0]); let _ = app.run(id, p); @@ -330,9 +363,21 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let base_tick = s.tempo.samples_to_ticks(clip.start, sr); let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1); let ruler = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, header.max.y + RULER_H)); - let roll = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y), pos2(ruler.max.x, full.max.y - VEL_H - 4.0)); + // Under the roll: the Velocity lane, then the track's controller lanes. + let lane_kinds = s.track(track).map(|t| t.midi_lanes.clone()).unwrap_or_default(); + let (vel_h, lane_h) = lane_heights(full.max.y - 2.0 - ruler.max.y, lane_kinds.len()); + let lanes_h = vel_h + lane_kinds.len() as f32 * (lane_h + LANE_GAP); + let roll = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y), pos2(ruler.max.x, full.max.y - 2.0 - lanes_h - LANE_GAP)); let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); - let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); + let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + LANE_GAP), pos2(roll.max.x, roll.max.y + LANE_GAP + vel_h)); + let lanes: Vec = lane_kinds + .iter() + .enumerate() + .map(|(i, &kind)| { + let top = vel.max.y + LANE_GAP + i as f32 * (lane_h + LANE_GAP); + LaneView { kind, rect: Rect::from_min_max(pos2(roll.min.x, top), pos2(roll.max.x, top + lane_h)) } + }) + .collect(); if !roll.is_positive() { return; } @@ -452,6 +497,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let px = m.ticks_per_bar() as f32 * rv.px_per_tick; (40.0 / px.max(0.01)).ceil().max(1.0) as i64 }; + let mut bars: Vec = Vec::new(); for bar in (first_bar..).take(4096) { let at = s.tempo.bar_start_tick(bar); if at - base_tick > tick_hi { @@ -467,6 +513,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } } let x = rv.x_of(at - base_tick); + bars.push(x); painter.line_segment([pos2(x, roll.min.y), pos2(x, roll.max.y)], Stroke::new(1.0, t.bar_line)); rp.line_segment([pos2(x, ruler.min.y + 3.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.ruler_tick)); if bar.rem_euclid(label_every) == 1 % label_every { @@ -536,7 +583,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let px = rv.x_of(play_tick.round() as i64); if (roll.min.x..=roll.max.x).contains(&px) { let (col, w) = if playing { (t.playhead, 1.5) } else { (t.insertion, 1.0) }; - ui.painter().line_segment([pos2(px, ruler.min.y), pos2(px, roll.max.y)], Stroke::new(w, col)); + ui.painter().line_segment([pos2(px, ruler.min.y), pos2(px, full.max.y - 2.0)], Stroke::new(w, col)); } // Interaction. let hit = |pos: Pos2| rv.hit(&sequence.notes, pos); @@ -572,6 +619,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.drag_started() && let Some(p) = origin.or_else(|| resp.interact_pointer_pos()) { + app.midi.ctrl_selected.clear(); match hit(p) { Some((i, kind)) => { if !app.midi.selected.contains(&i) { @@ -692,6 +740,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { if resp.clicked() && let Some(p) = resp.interact_pointer_pos() { + app.midi.ctrl_selected.clear(); match hit(p) { Some((i, _)) => { if mods.shift { @@ -733,11 +782,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { }); } if let (Some(text), Some(p)) = (info, pointer) { - let tip = ui.painter().with_clip_rect(full); - let galley = tip.layout_no_wrap(text, regular(10.5), t.text); - let at = pos2((p.x + 14.0).min(full.max.x - galley.size().x - 6.0), (p.y - 20.0).max(full.min.y + 2.0)); - tip.rect_filled(Rect::from_min_size(at, galley.size()).expand(3.0), 3.0, t.panel_bg2.gamma_multiply(0.95)); - tip.galley(at, galley, t.text); + tooltip(ui, full, p, text); } // Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid. if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() { @@ -776,23 +821,50 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { app.midi.selected = returned_indices(moved); } } - if !app.midi.selected.is_empty() + if (!app.midi.selected.is_empty() || !app.midi.ctrl_selected.is_empty()) && !ui.ctx().egui_wants_keyboard_input() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { - let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); + if app.midi.ctrl_selected.is_empty() { + let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); + } else { + let _ = app.run("midi.ctrl_delete", json!({"clip": cid.0, "indices": app.midi.ctrl_selected})); + } app.midi.selected.clear(); + app.midi.ctrl_selected.clear(); + } + let cx = LaneCtx { + rv, + full, + cid, + track, + seq: sequence, + color: base, + base_tick, + len_ticks, + grid: grid_ticks, + snapping, + tool: s.edit.tool, + shape: s.edit.pencil_shape, + tempo: &s.tempo, + bars: &bars, + }; + velocity_lane(app, ui, &cx); + for (i, lane) in lanes.iter().enumerate() { + crate::midi_lanes::ctrl_lane(app, ui, &cx, i, *lane); } - velocity_lane(app, ui, &rv, cid, &sequence.notes, base, full); + crate::midi_lanes::lane_headers(app, ui, &cx, &lanes); + app.midi.lanes = lanes; } /// The velocity lane: one stem per note. Dragging draws the velocity of every stem the pointer /// passes; with notes selected only theirs change, so one note of a chord can be set on its own. -fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, rv: &RollView, cid: ClipId, notes: &[Note], base: Color32, full: Rect) { +/// The Pencil's other shapes draw a line or curve across the stems instead. +fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx) { let t = Tokens::current(); + let (rv, cid, notes, base) = (&cx.rv, cx.cid, &cx.seq.notes, cx.color); let vel = rv.vel; ui.painter().rect_filled(vel, 0.0, t.playlist_bg.lerp_to_gamma(Color32::BLACK, 0.25)); - ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim); let painter = ui.painter().with_clip_rect(vel); let selected = app.midi.selected.clone(); let editable = |i: usize| selected.is_empty() || selected.contains(&i); @@ -810,6 +882,13 @@ fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, rv: &RollView, cid: ClipId, no painter.circle_filled(pos2(x, vel.max.y - 2.0 - h), 2.5, col); } let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag()); + if vresp.drag_started() || vresp.clicked() { + app.midi.ctrl_selected.clear(); + } + if cx.tool == Tool::Pencil && cx.shape != soundcraft_midi::PencilShape::FreeHand { + crate::midi_lanes::velocity_shape(app, ui, cx, &vresp, &editable); + return; + } let down = vresp.is_pointer_button_down_on() || vresp.clicked(); let Some(p) = vresp.interact_pointer_pos().filter(|_| down) else { if app.midi.vel_last.take().is_some() { diff --git a/crates/ui-egui/src/midi_lanes.rs b/crates/ui-egui/src/midi_lanes.rs new file mode 100644 index 000000000..011024205 --- /dev/null +++ b/crates/ui-egui/src/midi_lanes.rs @@ -0,0 +1,567 @@ +//! The MIDI Editor's controller lanes under the Velocity lane: CC, pitch bend, aftertouch and +//! program change, drawn as values held until the next event. Each MIDI track keeps its own list +//! of lanes (`midi.lanes`). The Pencil draws with the current shape (`edit.pencil_shape`), the +//! Grabber adds, selects and drags events, the Trimmer raises or lowers them, Alt-click deletes. + +use crate::SoundApp; +use crate::midi_editor::{RollView, position_label, returned_indices, snap_tick, tooltip}; +use crate::theme::{Tokens, regular}; +use egui::{Align2, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_midi::{CtrlKind, MAX_POINTS, Note, PencilShape, Sequence, ShapeStroke}; +use soundcraft_model::{ClipId, Tool, TrackId}; +use soundcraft_time::{TICKS_PER_QUARTER, TempoMap}; +use std::collections::HashSet; + +/// A controller lane's height, and the least a lane shrinks to when many are open. +pub const LANE_H: f32 = 56.0; +const LANE_MIN: f32 = 18.0; +pub const LANE_GAP: f32 = 4.0; +/// The Velocity lane's height, and the least it shrinks to. +pub const VEL_LANE_H: f32 = 62.0; +const VEL_MIN: f32 = 24.0; +/// How near the pointer (px) an event is hit. +const HIT: f32 = 5.0; +/// Space between a lane header and its lane, so a stroke started just left of a lane still +/// reaches the lane. +const HEADER_GAP: f32 = 12.0; +/// A stroke's event spacing, as `midi.ctrls_draw` uses: a 64th note. +const DRAW_STEP: i64 = TICKS_PER_QUARTER / 16; + +/// Heights of the Velocity lane and of each of `n` controller lanes: together at most 60% of `avail`. +pub fn lane_heights(avail: f32, n: usize) -> (f32, f32) { + if n == 0 { + return (VEL_LANE_H, 0.0); + } + let want = VEL_LANE_H + n as f32 * (LANE_H + LANE_GAP); + let cap = (avail * 0.6).max(0.0); + if want <= cap { + (VEL_LANE_H, LANE_H) + } else { + let k = cap / want; + ((VEL_LANE_H * k).max(VEL_MIN), (LANE_H * k).max(LANE_MIN)) + } +} + +/// One controller lane on screen. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LaneView { + pub kind: CtrlKind, + pub rect: Rect, +} + +impl LaneView { + /// Screen y of a value. + pub fn y_of(&self, value: f64) -> f32 { + let (lo, hi) = self.kind.range(); + let k = ((value - f64::from(lo)) / f64::from(hi - lo).max(1.0)) as f32; + self.rect.max.y - 2.0 - k.clamp(0.0, 1.0) * (self.rect.height() - 4.0) + } + + /// The whole value at screen y, within the kind's range. + pub fn value_at(&self, y: f32) -> f64 { + let (lo, hi) = self.kind.range(); + let k = f64::from((self.rect.max.y - 2.0 - y) / (self.rect.height() - 4.0).max(1.0)); + if k.is_finite() { (f64::from(lo) + k.clamp(0.0, 1.0) * f64::from(hi - lo)).round() } else { f64::from(self.kind.rest_value()) } + } +} + +/// A gesture in a lane, from press to release. +#[derive(Debug, Clone, PartialEq)] +pub enum LaneGesture { + /// The Pencil: Free Hand collects `points`; the other shapes draw `stroke`. + Draw { lane: usize, points: Vec<(i64, f64)>, stroke: ShapeStroke }, + /// Dragging the selected events (Grabber), or only their values (Trimmer). + Move { lane: usize, start: Pos2, anchor_tick: i64, dt: i64, dv: i64, copy: bool, values_only: bool }, + /// Rubber-band selection. + Band { lane: usize, start: Pos2 }, + /// A Pencil shape across the velocity stems. + Velocity { stroke: ShapeStroke }, +} + +impl LaneGesture { + fn lane(&self) -> Option { + match self { + LaneGesture::Draw { lane, .. } | LaneGesture::Move { lane, .. } | LaneGesture::Band { lane, .. } => Some(*lane), + LaneGesture::Velocity { .. } => None, + } + } +} + +/// What the lanes need from the editor for one frame. +pub struct LaneCtx<'a> { + pub rv: RollView, + pub full: Rect, + pub cid: ClipId, + pub track: TrackId, + pub seq: &'a Sequence, + pub color: Color32, + pub base_tick: i64, + pub len_ticks: i64, + pub grid: i64, + pub snapping: bool, + pub tool: Tool, + pub shape: PencilShape, + pub tempo: &'a TempoMap, + /// Screen x of the bar lines. + pub bars: &'a [f32], +} + +impl LaneCtx<'_> { + fn snap(&self, tick: f64) -> i64 { + if self.snapping { snap_tick(tick, self.base_tick, self.grid) } else { tick.round() as i64 }.max(0) + } +} + +/// How a value reads: pitch bend signed, a program change by its General MIDI name. +pub fn value_text(kind: CtrlKind, v: i64) -> String { + match kind { + CtrlKind::PitchBend => format!("{v:+}"), + CtrlKind::Program => format!("{} {}", v + 1, soundcraft_midi::gm_program_name(v.clamp(0, 127) as u8)), + _ => v.to_string(), + } +} + +/// A Free Hand stroke's points as `midi.ctrls_draw` keeps them: sorted, one per tick (the last). +fn free_hand(points: &[(i64, f64)]) -> Vec<(i64, f64)> { + let mut p: Vec<(i64, f64)> = points.iter().map(|&(t, v)| (t.max(0), v)).collect(); + p.sort_by_key(|x| x.0); + p.dedup_by(|later, kept| { + let same = later.0 == kept.0; + if same { + *kept = *later; + } + same + }); + p +} + +fn stroke_points(points: &[(i64, f64)], stroke: &ShapeStroke) -> Vec<(i64, f64)> { + if stroke.shape == PencilShape::FreeHand { free_hand(points) } else { stroke.points(DRAW_STEP) } +} + +/// An event as the lane shows it. +#[derive(Debug, Clone, Copy)] +struct Shown { + tick: i64, + value: i32, + /// Its index in the sequence (none for a stroke being drawn). + index: Option, + selected: bool, +} + +/// The lane's events with the gesture in progress applied: moved, copied or drawn over. +fn shown_events(app: &SoundApp, cx: &LaneCtx, li: usize, lane: LaneView) -> Vec { + let sel = &app.midi.ctrl_selected; + let g = app.midi.lane_gesture.as_ref().filter(|g| g.lane() == Some(li)); + let (lo, hi) = lane.kind.range(); + let drawn = match g { + Some(LaneGesture::Draw { points, stroke, .. }) => stroke_points(points, stroke), + _ => Vec::new(), + }; + let span = drawn.first().zip(drawn.last()).map(|(a, b)| (a.0, b.0)); + let mut out = Vec::new(); + let mut moved = Vec::new(); + for (i, c) in cx.seq.ctrls.iter().enumerate().filter(|(_, c)| c.kind == lane.kind) { + let selected = sel.contains(&i); + if let Some(LaneGesture::Move { dt, dv, copy, .. }) = g + && selected + { + let value = i64::from(c.value).saturating_add(*dv).clamp(i64::from(lo), i64::from(hi)) as i32; + moved.push(Shown { tick: c.tick.saturating_add(*dt).max(0), value, index: Some(i), selected: true }); + if *copy { + out.push(Shown { tick: c.tick, value: c.value, index: Some(i), selected: false }); + } + continue; + } + if span.is_some_and(|(a, b)| (a..=b).contains(&c.tick)) { + continue; + } + out.push(Shown { tick: c.tick, value: c.value, index: Some(i), selected }); + } + out.retain(|o| !moved.iter().any(|m| m.tick == o.tick)); + out.extend(moved); + out.extend(drawn.into_iter().map(|(tick, v)| Shown { + tick, + value: v.round().clamp(f64::from(lo), f64::from(hi)) as i32, + index: None, + selected: false, + })); + out.sort_by_key(|s| s.tick); + out +} + +fn lane_background(ui: &Ui, cx: &LaneCtx, r: Rect) { + let t = Tokens::current(); + ui.painter().rect_filled(r, 0.0, t.playlist_bg.lerp_to_gamma(Color32::BLACK, 0.25)); + let painter = ui.painter().with_clip_rect(r); + for &x in cx.bars { + painter.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, t.bar_line.gamma_multiply(0.6))); + } + let end_x = cx.rv.x_of(cx.len_ticks); + if end_x < r.max.x { + painter.rect_filled(Rect::from_min_max(pos2(end_x.max(r.min.x), r.min.y), r.max), 0.0, Color32::from_black_alpha(90)); + } +} + +/// One controller lane: drawing, hit testing and the tools. +pub fn ctrl_lane(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, li: usize, lane: LaneView) { + let t = Tokens::current(); + let r = lane.rect; + lane_background(ui, cx, r); + let painter = ui.painter().with_clip_rect(r); + let (lo, _) = lane.kind.range(); + if lane.kind.rest_value() != lo { + let y = lane.y_of(f64::from(lane.kind.rest_value())); + painter.line_segment([pos2(r.min.x, y), pos2(r.max.x, y)], Stroke::new(1.0, t.grid_line)); + } + // Held values: a step at each event, flat until the next one and to the clip's end. + let shown = shown_events(app, cx, li, lane); + let at = |s: &Shown| pos2(cx.rv.x_of(s.tick), lane.y_of(f64::from(s.value))); + let line = Stroke::new(1.5, cx.color); + for w in shown.windows(2) { + if let [a, b] = w { + let (pa, pb) = (at(a), at(b)); + painter.line_segment([pa, pos2(pb.x, pa.y)], line); + painter.line_segment([pos2(pb.x, pa.y), pb], line); + } + } + if let Some(last) = shown.last() { + let p = at(last); + painter.line_segment([p, pos2(cx.rv.x_of(cx.len_ticks).min(r.max.x), p.y)], line); + } + for s in &shown { + let p = at(s); + if !(r.min.x - HIT..=r.max.x + HIT).contains(&p.x) { + continue; + } + let dot = Rect::from_center_size(p, vec2(5.0, 5.0)); + if s.selected { + painter.rect(dot, 1.0, cx.color.lerp_to_gamma(Color32::WHITE, 0.65), Stroke::new(1.0, t.accent), StrokeKind::Outside); + } else { + painter.rect_filled(dot, 1.0, if s.index.is_some() { cx.color } else { cx.color.gamma_multiply(0.6) }); + } + } + let resp = ui.interact(r, ui.id().with(("cc_lane", cx.cid.0, li)), Sense::click_and_drag()); + let mods = ui.input(|i| i.modifiers); + let pointer = ui.ctx().pointer_latest_pos(); + let hit = |p: Pos2| { + shown + .iter() + .filter_map(|s| { + let q = at(s); + let d = (q.x - p.x).abs().max((q.y - p.y).abs()); + (d <= HIT).then_some((s.index?, d)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + }; + let in_lane = |i: &usize| cx.seq.ctrls.get(*i).is_some_and(|c| c.kind == lane.kind); + if resp.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) + { + app.midi.selected.clear(); + app.midi.ctrl_selected.retain(in_lane); + app.midi.lane_gesture = Some(match cx.tool { + Tool::Pencil => { + let tick = cx.rv.tick_at(p.x); + let tick = if cx.shape == PencilShape::FreeHand { (tick.round() as i64).max(0) } else { cx.snap(tick) }; + let from = (tick, lane.value_at(p.y)); + let seed = ui.input(|i| i.time).to_bits(); + LaneGesture::Draw { lane: li, points: vec![from], stroke: ShapeStroke { shape: cx.shape, from, to: from, period: cx.grid, seed } } + } + Tool::Trim => { + // The selected events, or every event in the lane when none is. + if app.midi.ctrl_selected.is_empty() { + app.midi.ctrl_selected = (0..cx.seq.ctrls.len()).filter(in_lane).collect(); + } + LaneGesture::Move { lane: li, start: p, anchor_tick: 0, dt: 0, dv: 0, copy: false, values_only: true } + } + _ => match hit(p) { + Some(i) => { + if !app.midi.ctrl_selected.contains(&i) { + if !mods.shift { + app.midi.ctrl_selected.clear(); + } + app.midi.ctrl_selected.push(i); + } + let anchor_tick = cx.seq.ctrls.get(i).map_or(0, |c| c.tick); + LaneGesture::Move { lane: li, start: p, anchor_tick, dt: 0, dv: 0, copy: mods.alt, values_only: false } + } + None => LaneGesture::Band { lane: li, start: p }, + }, + }); + } + let mut info: Option = None; + if let (Some(p), Some(g)) = (pointer, app.midi.lane_gesture.as_mut()) + && g.lane() == Some(li) + { + let value = lane.value_at(p.y); + match g { + LaneGesture::Draw { points, stroke, .. } => { + let tick = cx.rv.tick_at(p.x); + if stroke.shape == PencilShape::FreeHand { + let tick = (tick.round() as i64).max(0); + // Fill the gap since the last frame so a quick stroke leaves no holes. + if let Some(&(lt, lv)) = points.last() { + let n = (tick.saturating_sub(lt).abs() / DRAW_STEP).min(512); + for k in 1..n { + let f = k as f64 / n as f64; + points.push((lt + (tick.saturating_sub(lt) as f64 * f) as i64, lv + (value - lv) * f)); + } + } + if points.len() < MAX_POINTS as usize { + points.push((tick, value)); + } + info = Some(format!("{} {}", value_text(lane.kind, value as i64), position_label(cx.tempo, cx.base_tick + tick))); + } else { + stroke.to = (cx.snap(tick), value); + info = Some(format!("{} {}", stroke.shape.label(), value_text(lane.kind, value as i64))); + } + } + LaneGesture::Move { start, anchor_tick, dt, dv, values_only, .. } => { + if !*values_only { + let raw = f64::from(p.x - start.x) / f64::from(cx.rv.px_per_tick.max(f32::EPSILON)); + *dt = if cx.snapping { snap_tick(*anchor_tick as f64 + raw, cx.base_tick, cx.grid) - *anchor_tick } else { raw.round() as i64 }; + } + *dv = (value - lane.value_at(start.y)) as i64; + info = Some(if *values_only { + format!("{:+}", *dv) + } else { + format!("{:+} {}", *dv, position_label(cx.tempo, cx.base_tick + (*anchor_tick + *dt).max(0))) + }); + } + LaneGesture::Band { start, .. } => { + ui.painter().rect( + Rect::from_two_pos(*start, p).intersect(r), + 0.0, + Color32::from_rgba_unmultiplied(120, 170, 255, 40), + Stroke::new(1.0, Color32::from_rgb(120, 170, 255)), + StrokeKind::Inside, + ); + } + LaneGesture::Velocity { .. } => {} + } + } + let released = resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()); + if released && app.midi.lane_gesture.as_ref().is_some_and(|g| g.lane() == Some(li)) { + let kind = lane.kind.id(); + match app.midi.lane_gesture.take() { + Some(LaneGesture::Draw { points, stroke, .. }) => { + let p = if stroke.shape == PencilShape::FreeHand { + let pts: Vec = free_hand(&points).iter().map(|&(t, v)| json!([t, v])).collect(); + (pts.len() > 1).then(|| json!({"clip": cx.cid.0, "kind": kind, "points": pts})) + } else { + (stroke.from != stroke.to).then(|| { + json!({"clip": cx.cid.0, "kind": kind, "shape": stroke.shape.id(), "from": [stroke.from.0, stroke.from.1], + "to": [stroke.to.0, stroke.to.1], "period": cx.grid, "seed": stroke.seed}) + }) + }; + if let Some(p) = p { + let _ = app.run("midi.ctrls_draw", p); + app.midi.ctrl_selected.clear(); + } + } + Some(LaneGesture::Move { dt, dv, copy, .. }) if dt != 0 || dv != 0 => { + let sel = app.midi.ctrl_selected.clone(); + let r = app.run("midi.ctrls_move", json!({"clip": cx.cid.0, "indices": sel, "ticks": dt, "value": dv, "copy": copy})); + app.midi.ctrl_selected = returned_indices(r); + } + Some(LaneGesture::Band { start, .. }) => { + if let Some(p) = pointer { + let band = Rect::from_two_pos(start, p); + let picked = shown.iter().filter(|s| band.contains(at(s))).filter_map(|s| s.index); + if mods.shift { + for i in picked { + if !app.midi.ctrl_selected.contains(&i) { + app.midi.ctrl_selected.push(i); + } + } + } else { + app.midi.ctrl_selected = picked.collect(); + } + } + } + _ => {} + } + } + if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + app.midi.selected.clear(); + app.midi.ctrl_selected.retain(in_lane); + let add = json!({"clip": cx.cid.0, "kind": lane.kind.id(), "tick": cx.snap(cx.rv.tick_at(p.x)), "value": lane.value_at(p.y)}); + match (cx.tool, hit(p)) { + (_, Some(i)) if mods.alt => { + let _ = app.run("midi.ctrl_delete", json!({"clip": cx.cid.0, "indices": [i]})); + app.midi.ctrl_selected.clear(); + } + (Tool::Pencil, _) | (Tool::Grabber | Tool::Smart, None) => { + let r = app.run("midi.ctrl_add", add); + app.midi.ctrl_selected = r.ok().and_then(|v| v["index"].as_u64()).map(|i| vec![i as usize]).unwrap_or_default(); + } + (_, Some(i)) => { + if mods.shift { + if let Some(k) = app.midi.ctrl_selected.iter().position(|&x| x == i) { + app.midi.ctrl_selected.remove(k); + } else { + app.midi.ctrl_selected.push(i); + } + } else { + app.midi.ctrl_selected = vec![i]; + } + } + (_, None) => app.midi.ctrl_selected.clear(), + } + } + if info.is_none() + && app.midi.lane_gesture.is_none() + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + info = Some(match hit(p).and_then(|i| cx.seq.ctrls.get(i)) { + Some(c) => { + format!("{} {} {}", lane.kind.label(), value_text(lane.kind, i64::from(c.value)), position_label(cx.tempo, cx.base_tick + c.tick)) + } + None => format!("{} {}", lane.kind.label(), value_text(lane.kind, lane.value_at(p.y) as i64)), + }); + } + if let (Some(text), Some(p)) = (info, pointer) { + tooltip(ui, cx.full, p, text); + } +} + +/// The Pencil's shapes in the Velocity lane: a stroke sets each note it spans to the shape's value +/// at the note's start (only the selected notes when some are). Free Hand is the lane's own stroke. +pub fn velocity_shape(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, vresp: &Response, editable: &dyn Fn(usize) -> bool) { + let rv = cx.rv; + let vel = rv.vel; + let pointer = ui.ctx().pointer_latest_pos(); + let at = |p: Pos2| ((rv.tick_at(p.x).round() as i64).max(0), rv.velocity_at(p.y) as f64); + if vresp.drag_started() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| vresp.interact_pointer_pos()) + { + let from = at(p); + let seed = ui.input(|i| i.time).to_bits(); + app.midi.lane_gesture = Some(LaneGesture::Velocity { stroke: ShapeStroke { shape: cx.shape, from, to: from, period: cx.grid, seed } }); + } + let Some(LaneGesture::Velocity { stroke }) = app.midi.lane_gesture.as_mut() else { return }; + if let Some(p) = pointer { + stroke.to = at(p); + } + let stroke = *stroke; + let y = |v: f64| vel.max.y - 2.0 - (vel.height() - 4.0) * (v as f32 / 127.0); + let pts: Vec = stroke.points(DRAW_STEP).iter().map(|&(t, v)| pos2(rv.x_of(t), y(v))).collect(); + ui.painter().with_clip_rect(vel).add(egui::Shape::line(pts, Stroke::new(1.5, Tokens::current().accent))); + if vresp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + app.midi.lane_gesture = None; + let (lo, hi) = stroke.span(); + let notes: &[Note] = &cx.seq.notes; + for (i, n) in notes.iter().enumerate().filter(|(i, n)| editable(*i) && (lo..=hi).contains(&n.start)) { + let v = stroke.value_at(n.start).round().clamp(1.0, 127.0) as i64; + if i64::from(n.velocity) != v { + let _ = app.engine.execute_merged( + "midi.note_edit", + &json!({"clip": cx.cid.0, "index": i, "velocity": v}), + &format!("velshape:{}", cx.cid.0), + ); + } + } + app.engine.end_merge(); + } +} + +/// The menu of controllers: the common ones, then every CC number. Kinds with events in the clip +/// are marked. Returns the one picked. +fn kind_menu(ui: &mut Ui, present: &HashSet, current: Option) -> Option { + let mut pick = None; + let label = |k: CtrlKind| if present.contains(&k) { format!("{} •", k.label()) } else { k.label() }; + for k in CtrlKind::COMMON { + if ui.selectable_label(current == Some(k), label(k)).clicked() { + pick = Some(k); + ui.close(); + } + } + ui.menu_button("Other Controllers", |ui| { + for (a, b) in [(0u8, 31u8), (32, 63), (64, 95), (96, 127)] { + ui.menu_button(format!("CC {a}–{b}"), |ui| { + for n in a..=b { + let k = CtrlKind::Cc(n); + if ui.selectable_label(current == Some(k), label(k)).clicked() { + pick = Some(k); + ui.close(); + } + } + }); + } + }); + pick +} + +fn set_lanes(app: &mut SoundApp, track: TrackId, lanes: &[CtrlKind]) { + let ids: Vec = lanes.iter().map(|k| k.id()).collect(); + let _ = app.run("midi.lanes", json!({"track": track.0, "lanes": ids})); +} + +/// The lane headers in the keys column: the Velocity lane's menu adds lanes; a controller lane's +/// menu changes what it shows or removes it. +pub fn lane_headers(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, lanes: &[LaneView]) { + let t = Tokens::current(); + let x0 = cx.full.min.x; + let x1 = cx.rv.roll.min.x; + let present: HashSet = cx.seq.ctrls.iter().map(|c| c.kind).collect(); + let kinds: Vec = lanes.iter().map(|l| l.kind).collect(); + let header = |ui: &mut Ui, r: Rect, name: &str, salt: (u64, usize)| -> Response { + let resp = ui.interact(r, ui.id().with(("lane_header", salt)), Sense::click()); + let fill = if resp.hovered() { t.panel_bg2.lerp_to_gamma(t.accent, 0.15) } else { t.panel_bg2 }; + ui.painter().rect_filled(r, 0.0, fill); + let p = ui.painter().with_clip_rect(r); + let rows = r.height() >= 26.0; + p.text(pos2(r.min.x + 4.0, if rows { r.min.y + 9.0 } else { r.center().y }), Align2::LEFT_CENTER, name, regular(9.5), t.text); + // A small down-pointing triangle under the name: the header opens a menu. + if rows { + let c = pos2(r.min.x + 8.0, r.min.y + 21.0); + p.add(egui::Shape::convex_polygon(vec![c + vec2(-3.0, -1.5), c + vec2(3.0, -1.5), c + vec2(0.0, 2.0)], t.text_dim, Stroke::NONE)); + } + resp + }; + let vel_r = Rect::from_min_max(pos2(x0, cx.rv.vel.min.y), pos2(x1 - HEADER_GAP, cx.rv.vel.max.y)); + let vresp = header(ui, vel_r, "Velocity", (cx.cid.0, usize::MAX)).on_hover_text("Add a controller lane"); + egui::Popup::menu(&vresp).show(|ui| { + ui.menu_button("Add Controller Lane", |ui| { + if let Some(k) = kind_menu(ui, &present, None) + && !kinds.contains(&k) + { + let mut v = kinds.clone(); + v.push(k); + set_lanes(app, cx.track, &v); + } + }); + let missing: Vec = present.iter().copied().filter(|k| !kinds.contains(k)).collect(); + if ui.add_enabled(!missing.is_empty(), egui::Button::new("Show Lanes with Data")).clicked() { + let mut v = kinds.clone(); + let mut more = missing; + more.sort_by_key(|k| k.id()); + v.extend(more); + set_lanes(app, cx.track, &v); + ui.close(); + } + }); + for (i, lane) in lanes.iter().enumerate() { + let r = Rect::from_min_max(pos2(x0, lane.rect.min.y), pos2(x1 - HEADER_GAP, lane.rect.max.y)); + let resp = header(ui, r, &lane.kind.short(), (cx.cid.0, i)).on_hover_text(lane.kind.label()); + egui::Popup::menu(&resp).show(|ui| { + if let Some(k) = kind_menu(ui, &present, Some(lane.kind)) { + // This lane shows `k` now; another lane that showed it goes away. + let v: Vec = + kinds.iter().enumerate().filter(|&(j, x)| j == i || *x != k).map(|(j, x)| if j == i { k } else { *x }).collect(); + set_lanes(app, cx.track, &v); + } + ui.separator(); + if ui.button("Remove Lane").clicked() { + let v: Vec = kinds.iter().copied().filter(|x| *x != lane.kind).collect(); + set_lanes(app, cx.track, &v); + ui.close(); + } + }); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 41d83ac4d..e393667e2 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -170,9 +170,23 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ] { let smart_member = cur == Tool::Smart && matches!(tool, Tool::Trim | Tool::Selector | Tool::Grabber); let tip = format!("{}{}", tool.label(), tool.fkey().map(|k| format!(" ({k})")).unwrap_or_default()); - if icon_button(ui, vec2(36.0, 26.0), icon, cur == tool || smart_member, &tip).clicked() { + let shape = app.engine.session().edit.pencil_shape; + // Right-click the Pencil for its shapes. + let resp = icon_button(ui, vec2(36.0, 26.0), icon, cur == tool || smart_member, &tip); + if resp.clicked() { let _ = app.run("edit.tool", json!({"tool": tool.id()})); } + if tool == Tool::Pencil { + resp.context_menu(|ui| { + for s in soundcraft_midi::PencilShape::ALL { + if ui.selectable_label(shape == s, s.label()).clicked() { + let _ = app.run("edit.pencil_shape", json!({"shape": s.id()})); + let _ = app.run("edit.tool", json!({"tool": "pencil"})); + ui.close(); + } + } + }); + } } }); // Smart tool bracket above Trim/Selector/Grabber. diff --git a/crates/ui-egui/tests/midi_cc_lanes.rs b/crates/ui-egui/tests/midi_cc_lanes.rs new file mode 100644 index 000000000..231f688e5 --- /dev/null +++ b/crates/ui-egui/tests/midi_cc_lanes.rs @@ -0,0 +1,214 @@ +//! MIDI Editor controller lanes: the track's lanes, the Pencil's shapes, the Grabber and the +//! Trimmer on controller events, and Pencil shapes in the velocity lane. +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use soundcraft_midi::CtrlKind; +use soundcraft_ui_egui::midi_editor::RollView; +use soundcraft_ui_egui::midi_lanes::LaneView; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + app: SoundApp, + ctx: egui::Context, + clip: Value, +} + +impl Rig { + /// A MIDI track with a 16-beat clip open in the MIDI Editor (Grid mode, 1/16 grid) and the + /// given controller lanes. + fn new(notes: &[(u8, i64, i64)], lanes: &[&str]) -> Self { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name": "Keys", "kind": "midi"})).unwrap(); + let q = app.engine.session().sample_rate.samples(0.5); + let clip = app.run("midi.new_clip", json!({"track": "Keys", "start": 0, "end": 16 * q})).unwrap()["clip"].clone(); + for &(pitch, start, length) in notes { + app.run("midi.note_add", json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": length})).unwrap(); + } + app.run("midi.lanes", json!({"track": "Keys", "lanes": lanes})).unwrap(); + app.run("edit.select", json!({"clips": [clip]})).unwrap(); + app.run("view.grid", json!({"value": "1/16"})).unwrap(); + app.run("edit.mode", json!({"mode": "grid"})).unwrap(); + app.ui.show_midi_editor = true; + let mut r = Rig { app, ctx: egui::Context::default(), clip }; + r.frames(6); + r + } + + fn frame(&mut self, events: Vec, modifiers: Modifiers) { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let app = &mut self.app; + self.ctx + .run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![], Modifiers::NONE); + } + } + + fn view(&self) -> RollView { + self.app.midi.view.unwrap() + } + + fn lane(&self, i: usize) -> LaneView { + self.app.midi.lanes[i] + } + + /// The point in lane `i` at a clip tick and value. + fn at(&self, i: usize, tick: i64, value: f64) -> Pos2 { + pos2(self.view().x_of(tick), self.lane(i).y_of(value)) + } + + fn drag(&mut self, from: Pos2, to: Pos2, modifiers: Modifiers) { + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + self.frame(vec![Event::PointerMoved(from)], modifiers); + self.frame(vec![button(from, true)], modifiers); + for step in 1..=8 { + self.frame(vec![Event::PointerMoved(from.lerp(to, step as f32 / 8.0))], modifiers); + } + self.frame(vec![button(to, false)], modifiers); + self.frames(2); + } + + fn click(&mut self, at: Pos2, modifiers: Modifiers) { + self.frame(vec![Event::PointerMoved(at)], modifiers); + self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers }], modifiers); + self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers }], modifiers); + self.frames(2); + } + + fn key(&mut self, key: Key) { + self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], Modifiers::NONE); + self.frames(1); + } + + /// (tick, value) of the clip's events of `kind`. + fn ctrls(&mut self, kind: &str) -> Vec<(i64, i64)> { + let r = self.app.run("midi.ctrls", json!({"clip": self.clip, "kind": kind})).unwrap(); + r["ctrls"].as_array().unwrap().iter().map(|c| (c["tick"].as_i64().unwrap(), c["value"].as_i64().unwrap())).collect() + } + + fn add(&mut self, kind: &str, tick: i64, value: i64) { + self.app.run("midi.ctrl_add", json!({"clip": self.clip, "kind": kind, "tick": tick, "value": value})).unwrap(); + } + + fn velocities(&mut self) -> Vec { + let n = self.app.run("midi.notes", json!({"clip": self.clip})).unwrap(); + n["notes"].as_array().unwrap().iter().map(|n| n["velocity"].as_i64().unwrap()).collect() + } +} + +#[test] +fn the_tracks_lanes_sit_under_the_velocity_lane() { + let mut r = Rig::new(&[], &[]); + let roll_alone = r.view().roll; + assert!(r.app.midi.lanes.is_empty()); + r.app.run("midi.lanes", json!({"track": "Keys", "lanes": ["cc1", "pitchbend"]})).unwrap(); + r.frames(2); + let (v, a, b) = (r.view(), r.lane(0), r.lane(1)); + assert_eq!((a.kind, b.kind), (CtrlKind::Cc(1), CtrlKind::PitchBend)); + assert!(v.roll.max.y < roll_alone.max.y, "the roll makes room"); + assert!(v.vel.max.y <= a.rect.min.y && a.rect.max.y <= b.rect.min.y); + assert!(b.rect.max.y <= 1000.0); +} + +#[test] +fn a_pencil_line_draws_a_ramp_from_the_press_to_the_release() { + let mut r = Rig::new(&[], &["cc1"]); + r.app.run("edit.tool", json!({"tool": "pencil"})).unwrap(); + r.app.run("edit.pencil_shape", json!({"shape": "line"})).unwrap(); + r.frames(1); + r.drag(r.at(0, 0, 0.0), r.at(0, 1920, 127.0), Modifiers::NONE); + let ev = r.ctrls("cc1"); + assert!(ev.len() > 10, "{ev:?}"); + assert_eq!(ev.first().map(|e| e.0), Some(0)); + assert_eq!(ev.last().map(|e| e.0), Some(1920)); + assert!(ev.first().unwrap().1 <= 2 && ev.last().unwrap().1 >= 125, "{ev:?}"); + assert!(ev.windows(2).all(|w| w[0].1 < w[1].1), "rising: {ev:?}"); + r.app.run("edit.undo", json!({})).unwrap(); + assert!(r.ctrls("cc1").is_empty(), "one undo step"); +} + +#[test] +fn a_free_hand_stroke_replaces_what_it_crosses() { + let mut r = Rig::new(&[], &["cc11"]); + r.add("cc11", 0, 10); + r.add("cc11", 960, 10); + r.add("cc11", 3000, 10); + r.app.run("edit.tool", json!({"tool": "pencil"})).unwrap(); + r.frames(1); + r.drag(r.at(0, 480, 100.0), r.at(0, 1440, 100.0), Modifiers::NONE); + let ev = r.ctrls("cc11"); + assert_eq!(ev.first(), Some(&(0, 10))); + assert_eq!(ev.last(), Some(&(3000, 10))); + assert!(!ev.contains(&(960, 10)), "{ev:?}"); + assert!(ev.iter().any(|&(t, v)| (470..=500).contains(&t) && (98..=101).contains(&v)), "{ev:?}"); +} + +#[test] +fn the_grabber_adds_moves_and_alt_click_deletes() { + let mut r = Rig::new(&[], &["cc7"]); + r.app.run("edit.tool", json!({"tool": "grabber"})).unwrap(); + r.frames(1); + // A click on empty lane adds an event on the grid. + r.click(r.at(0, 250, 64.0), Modifiers::NONE); + assert_eq!(r.ctrls("cc7"), vec![(240, 64)]); + assert_eq!(r.app.midi.ctrl_selected, vec![0]); + // Dragging it moves it by whole grid steps and changes its value. + r.drag(r.at(0, 240, 64.0), r.at(0, 960, 100.0), Modifiers::NONE); + assert_eq!(r.ctrls("cc7"), vec![(960, 100)]); + r.click(r.at(0, 960, 100.0), Modifiers::ALT); + assert!(r.ctrls("cc7").is_empty()); +} + +#[test] +fn a_band_selects_events_and_delete_removes_only_them() { + let mut r = Rig::new(&[(60, 0, 480)], &["cc64"]); + for t in [0, 480, 960, 1440] { + r.add("cc64", t, 127); + } + r.app.run("edit.tool", json!({"tool": "grabber"})).unwrap(); + r.frames(1); + let lane = r.lane(0).rect; + r.drag(pos2(r.view().x_of(400), lane.min.y + 1.0), pos2(r.view().x_of(1000), lane.max.y - 1.0), Modifiers::NONE); + assert_eq!(r.app.midi.ctrl_selected, vec![1, 2]); + r.frame(vec![Event::PointerMoved(lane.center())], Modifiers::NONE); + r.key(Key::Delete); + assert_eq!(r.ctrls("cc64"), vec![(0, 127), (1440, 127)]); + assert_eq!(r.velocities().len(), 1, "the note stays"); +} + +#[test] +fn the_trimmer_raises_every_event_in_the_lane() { + let mut r = Rig::new(&[], &["cc1"]); + for (t, v) in [(0, 10), (480, 40), (960, 120)] { + r.add("cc1", t, v); + } + r.app.run("edit.tool", json!({"tool": "trim"})).unwrap(); + r.frames(1); + r.drag(r.at(0, 700, 50.0), r.at(0, 700, 70.0), Modifiers::NONE); + assert_eq!(r.ctrls("cc1"), vec![(0, 30), (480, 60), (960, 127)]); +} + +#[test] +fn a_pencil_line_in_the_velocity_lane_ramps_the_notes() { + let mut r = Rig::new(&[(60, 0, 240), (60, 480, 240), (60, 960, 240), (60, 1440, 240)], &[]); + r.app.run("edit.tool", json!({"tool": "pencil"})).unwrap(); + r.app.run("edit.pencil_shape", json!({"shape": "line"})).unwrap(); + r.frames(1); + let v = r.view(); + let y = |vel: f32| v.vel.max.y - 2.0 - (v.vel.height() - 4.0) * vel / 127.0; + r.drag(pos2(v.x_of(0), y(20.0)), pos2(v.x_of(1440), y(120.0)), Modifiers::NONE); + let vel = r.velocities(); + assert!(vel.windows(2).all(|w| w[0] < w[1]), "{vel:?}"); + assert!((18..=22).contains(&vel[0]) && (118..=122).contains(&vel[3]), "{vel:?}"); + r.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(r.velocities(), vec![100; 4], "one undo step"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cd40ace01..f64a5b3aa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (MIDI Editor controller lanes and the Pencil's shapes; previously: drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -44,7 +44,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) | | Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Mix and Edit on screen at once (Window › Mix in Separate Window, its own native window) ✅; Delay Compensation view and HEAT missing | -| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow | +| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); controller lanes per track (CC, pitch bend, aftertouch, program change) edited with the Grabber, Trimmer and the Pencil's seven shapes; notation editing shallow | | Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | | | Workspace / sound browser with previews | ❌ | G-16 | | I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | @@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | MIDI Editor controller lanes (saved per track) and the Pencil's shapes (Free Hand, Line, Triangle, Square, Random, Parabolic, S-Curve) in controller and velocity lanes. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) | | 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged | From a42397bffae237faf40ebaa8030b37d1113f6465 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Wed, 21 Oct 2026 13:16:46 +0200 Subject: [PATCH 07/10] MIDI Editor: lane headers are named buttons; tests for their menus Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/midi_lanes.rs | 8 +- crates/ui-egui/tests/midi_lane_menus.rs | 98 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/midi_lane_menus.rs diff --git a/crates/ui-egui/src/midi_lanes.rs b/crates/ui-egui/src/midi_lanes.rs index 011024205..384da9f1f 100644 --- a/crates/ui-egui/src/midi_lanes.rs +++ b/crates/ui-egui/src/midi_lanes.rs @@ -510,8 +510,10 @@ pub fn lane_headers(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, lanes: &[Lane let x1 = cx.rv.roll.min.x; let present: HashSet = cx.seq.ctrls.iter().map(|c| c.kind).collect(); let kinds: Vec = lanes.iter().map(|l| l.kind).collect(); - let header = |ui: &mut Ui, r: Rect, name: &str, salt: (u64, usize)| -> Response { + let header = |ui: &mut Ui, r: Rect, name: &str, full_name: &str, salt: (u64, usize)| -> Response { let resp = ui.interact(r, ui.id().with(("lane_header", salt)), Sense::click()); + let label = format!("{full_name} lane"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); let fill = if resp.hovered() { t.panel_bg2.lerp_to_gamma(t.accent, 0.15) } else { t.panel_bg2 }; ui.painter().rect_filled(r, 0.0, fill); let p = ui.painter().with_clip_rect(r); @@ -525,7 +527,7 @@ pub fn lane_headers(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, lanes: &[Lane resp }; let vel_r = Rect::from_min_max(pos2(x0, cx.rv.vel.min.y), pos2(x1 - HEADER_GAP, cx.rv.vel.max.y)); - let vresp = header(ui, vel_r, "Velocity", (cx.cid.0, usize::MAX)).on_hover_text("Add a controller lane"); + let vresp = header(ui, vel_r, "Velocity", "Velocity", (cx.cid.0, usize::MAX)).on_hover_text("Add a controller lane"); egui::Popup::menu(&vresp).show(|ui| { ui.menu_button("Add Controller Lane", |ui| { if let Some(k) = kind_menu(ui, &present, None) @@ -548,7 +550,7 @@ pub fn lane_headers(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, lanes: &[Lane }); for (i, lane) in lanes.iter().enumerate() { let r = Rect::from_min_max(pos2(x0, lane.rect.min.y), pos2(x1 - HEADER_GAP, lane.rect.max.y)); - let resp = header(ui, r, &lane.kind.short(), (cx.cid.0, i)).on_hover_text(lane.kind.label()); + let resp = header(ui, r, &lane.kind.short(), &lane.kind.label(), (cx.cid.0, i)).on_hover_text(lane.kind.label()); egui::Popup::menu(&resp).show(|ui| { if let Some(k) = kind_menu(ui, &present, Some(lane.kind)) { // This lane shows `k` now; another lane that showed it goes away. diff --git a/crates/ui-egui/tests/midi_lane_menus.rs b/crates/ui-egui/tests/midi_lane_menus.rs new file mode 100644 index 000000000..f5f06441b --- /dev/null +++ b/crates/ui-egui/tests/midi_lane_menus.rs @@ -0,0 +1,98 @@ +//! MIDI Editor lane header menus: the Velocity header adds controller lanes, a controller lane's +//! header changes what it shows or removes it. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_midi::CtrlKind; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// A MIDI track "Keys" with a clip open in the MIDI Editor, showing `lanes`. +fn harness(lanes: &[&str]) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name": "Keys", "kind": "midi"})).unwrap(); + let clip = app.run("midi.new_clip", json!({"track": "Keys", "start": 0, "end": 96_000})).unwrap()["clip"].clone(); + app.run("midi.ctrl_add", json!({"clip": clip, "kind": "cc64", "tick": 0, "value": 127})).unwrap(); + app.run("midi.ctrl_add", json!({"clip": clip, "kind": "pitchbend", "tick": 0, "value": 100})).unwrap(); + app.run("midi.lanes", json!({"track": "Keys", "lanes": lanes})).unwrap(); + app.run("edit.select", json!({"clips": [clip]})).unwrap(); + app.ui.show_midi_editor = true; + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn lanes(h: &Harness) -> Vec { + h.state().engine.session().tracks[0].midi_lanes.clone() +} + +fn open(h: &mut Harness, header: &str) { + h.get_by_label(header).click(); + h.run_steps(3); +} + +fn hover(h: &mut Harness, item: &str) { + h.get_by_label(item).hover(); + h.run_steps(3); +} + +fn pick(h: &mut Harness, item: &str) { + h.get_by_label(item).click(); + h.run_steps(3); +} + +#[test] +fn the_velocity_header_adds_a_lane() { + let mut h = harness(&[]); + open(&mut h, "Velocity lane"); + hover(&mut h, "Add Controller Lane ⏵"); + pick(&mut h, "Mod Wheel (CC 1)"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(1)]); + assert!(h.query_by_label("Mod Wheel (CC 1) lane").is_some(), "the new lane has a header"); +} + +#[test] +fn the_velocity_header_shows_the_lanes_the_clip_uses() { + let mut h = harness(&["cc1"]); + open(&mut h, "Velocity lane"); + pick(&mut h, "Show Lanes with Data"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(1), CtrlKind::Cc(64), CtrlKind::PitchBend]); +} + +#[test] +fn a_lane_header_changes_its_controller() { + let mut h = harness(&["cc1", "cc7"]); + open(&mut h, "Mod Wheel (CC 1) lane"); + // The clip's sustain events are marked. + pick(&mut h, "Sustain (CC 64) •"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(64), CtrlKind::Cc(7)]); + // Picking a controller another lane shows moves it here. + open(&mut h, "Volume (CC 7) lane"); + pick(&mut h, "Sustain (CC 64) •"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(64)]); +} + +#[test] +fn a_lane_header_reaches_every_cc_number() { + let mut h = harness(&["cc1"]); + open(&mut h, "Mod Wheel (CC 1) lane"); + hover(&mut h, "Other Controllers ⏵"); + hover(&mut h, "CC 0–31 ⏵"); + pick(&mut h, "CC 3"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(3)]); +} + +#[test] +fn a_lane_header_removes_its_lane() { + let mut h = harness(&["cc1", "pitchbend"]); + open(&mut h, "Pitch Bend lane"); + pick(&mut h, "Remove Lane"); + assert_eq!(lanes(&h), vec![CtrlKind::Cc(1)]); +} From f32e7451ff22a3c81814443113f739a899612141 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Wed, 21 Oct 2026 16:30:38 +0200 Subject: [PATCH 08/10] Session: sanitize midi_lanes on load, as midi.lanes does A session file could carry lanes that midi.lanes rejects (an id that does not round-trip through CtrlKind::from_id, duplicates, a lane on a non-MIDI track, or an unbounded list). The header menu sends the whole list back through midi.lanes, so one bad lane made every edit of that track's lanes fail, and a very long list left the MIDI Editor with no roll and no headers. Apply the same rules the command enforces: keep ids that parse, drop duplicates, cap at 128, clear lanes on tracks that are not MIDI. --- crates/model/src/session.rs | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 73fc164b1..4b9c9a332 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -569,6 +569,22 @@ impl Session { t.mixer.sanitize(); truncate_chars(&mut t.name, MAX_NAME_CHARS); truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); + // `midi_lanes` comes from the file: keep only what `midi.lanes` would accept, so the + // header menu can still send the list back. An id `CtrlKind::from_id` rejects, a + // duplicate, or a lane on a non-MIDI track makes every edit of that track's lanes fail. + if t.kind.is_midi() { + let mut kept: Vec = Vec::new(); + t.midi_lanes.retain(|k| { + let ok = soundcraft_midi::CtrlKind::from_id(&k.id()) == Some(*k) && !kept.contains(k); + if ok { + kept.push(*k); + } + ok + }); + t.midi_lanes.truncate(MAX_MIDI_LANES); + } else { + t.midi_lanes.clear(); + } if t.playlists.is_empty() { t.playlists.push(crate::Playlist::new(t.name.clone())); } @@ -642,6 +658,8 @@ impl Session { /// The most characters kept of a name read from a file: longer ones can't be shown, and drawing /// a name of a million characters overflows the renderer's buffers. pub const MAX_NAME_CHARS: usize = 256; +/// The most controller lanes a track keeps: the same cap `midi.lanes` enforces. +pub const MAX_MIDI_LANES: usize = 128; /// The most characters kept of a comment read from a file. pub const MAX_COMMENT_CHARS: usize = 4096; @@ -789,4 +807,36 @@ mod tests { assert_eq!(tr.mixer.volume_db, 0.0); assert!(s.next_id > t.0); } + + #[test] + fn sanitize_keeps_only_the_lanes_midi_lanes_accepts() { + use soundcraft_midi::CtrlKind; + let mut s = Session::default(); + let midi = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(t) = s.track_mut(midi) { + // An id `CtrlKind::from_id` rejects (`cc200`), a duplicate, and two valid lanes. + t.midi_lanes = vec![CtrlKind::Cc(200), CtrlKind::Cc(1), CtrlKind::Cc(1), CtrlKind::PolyPressure(60)]; + } + if let Some(t) = s.track_mut(audio) { + t.midi_lanes = vec![CtrlKind::Cc(7)]; + } + s.sanitize(); + assert_eq!(s.track(midi).unwrap().midi_lanes, vec![CtrlKind::Cc(1), CtrlKind::PolyPressure(60)]); + assert!(s.track(audio).unwrap().midi_lanes.is_empty(), "an audio track keeps no lanes"); + } + + #[test] + fn sanitize_caps_the_lane_list() { + use soundcraft_midi::CtrlKind; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let mut lanes: Vec = (0..=127u8).map(CtrlKind::Cc).collect(); + lanes.extend([CtrlKind::PolyPressure(0), CtrlKind::PolyPressure(1)]); + if let Some(tr) = s.track_mut(t) { + tr.midi_lanes = lanes; + } + s.sanitize(); + assert_eq!(s.track(t).unwrap().midi_lanes.len(), MAX_MIDI_LANES); + } } From 61b17a44ff80a6d567f1021bbebf31f022409c42 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Wed, 21 Oct 2026 16:30:42 +0200 Subject: [PATCH 09/10] MIDI Editor: lane heights keep the roll, so lane headers stay reachable lane_heights promised at most 60% of the available height, but the VEL_MIN and LANE_MIN floors broke that cap once a track had many lanes: the roll rect went non-positive and midi_editor::show returned before lane_headers ran, leaving a blank editor with no way to remove a lane. A clip with poly aftertouch has one kind per pitch, so this was reachable without any unusual input. Give the Velocity lane its floor first and split what remains between the controller lanes, which may shrink below LANE_MIN. The editor also keeps MIN_ROLL_H of roll and shrinks the whole band to fit, covering the case where the gaps alone fill the cap. --- crates/ui-egui/src/midi_editor.rs | 17 ++++++++++-- crates/ui-egui/src/midi_lanes.rs | 46 ++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index a9864db79..6b15f7f83 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -12,6 +12,9 @@ use soundcraft_time::{TICKS_PER_QUARTER, TempoMap}; const KEY_W: f32 = 48.0; const RULER_H: f32 = 18.0; +/// The least height the note roll keeps: the lane headers are drawn after it, so a roll with no +/// height would hide them and leave no way to remove a lane. +const MIN_ROLL_H: f32 = 80.0; /// Default, smallest and largest key row height (the vertical zoom). const ROW_H: f32 = 9.0; const ROW_MIN: f32 = 4.0; @@ -365,8 +368,18 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let ruler = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, header.max.y + RULER_H)); // Under the roll: the Velocity lane, then the track's controller lanes. let lane_kinds = s.track(track).map(|t| t.midi_lanes.clone()).unwrap_or_default(); - let (vel_h, lane_h) = lane_heights(full.max.y - 2.0 - ruler.max.y, lane_kinds.len()); - let lanes_h = vel_h + lane_kinds.len() as f32 * (lane_h + LANE_GAP); + let avail_h = full.max.y - 2.0 - ruler.max.y; + let (mut vel_h, mut lane_h) = lane_heights(avail_h, lane_kinds.len()); + let mut lanes_h = vel_h + lane_kinds.len() as f32 * (lane_h + LANE_GAP); + // The lanes never take the whole editor: the roll is what is left over, and a roll without + // height returns below before the lane headers are drawn, so a track with more lanes than fit + // could not remove any of them. Shrink the band until the roll keeps `MIN_ROLL_H`. + let max_lanes_h = (avail_h - MIN_ROLL_H).max(0.0); + if lanes_h > max_lanes_h { + vel_h *= (max_lanes_h / lanes_h).max(0.0); + lane_h = ((max_lanes_h - vel_h) / lane_kinds.len() as f32 - LANE_GAP).max(0.0); + lanes_h = vel_h + lane_kinds.len() as f32 * (lane_h + LANE_GAP); + } let roll = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y), pos2(ruler.max.x, full.max.y - 2.0 - lanes_h - LANE_GAP)); let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + LANE_GAP), pos2(roll.max.x, roll.max.y + LANE_GAP + vel_h)); diff --git a/crates/ui-egui/src/midi_lanes.rs b/crates/ui-egui/src/midi_lanes.rs index 384da9f1f..9f818bd37 100644 --- a/crates/ui-egui/src/midi_lanes.rs +++ b/crates/ui-egui/src/midi_lanes.rs @@ -38,8 +38,15 @@ pub fn lane_heights(avail: f32, n: usize) -> (f32, f32) { if want <= cap { (VEL_LANE_H, LANE_H) } else { + // The floors must not push the lanes past the cap: the editor gives the roll whatever is + // left, and a non-positive roll returns before the headers are drawn, so a track with many + // lanes could not remove any of them. The Velocity lane keeps its floor first, and the + // lanes share what remains: they may shrink below LANE_MIN, because an empty lane still + // shows its header and a header is what the user needs to remove it. let k = cap / want; - ((VEL_LANE_H * k).max(VEL_MIN), (LANE_H * k).max(LANE_MIN)) + let vel = (VEL_LANE_H * k).max(VEL_MIN).min(cap); + let lane = ((cap - vel) / n as f32 - LANE_GAP).max(0.0).min((LANE_H * k).max(LANE_MIN)); + (vel, lane) } } @@ -567,3 +574,40 @@ pub fn lane_headers(app: &mut SoundApp, ui: &mut Ui, cx: &LaneCtx, lanes: &[Lane }); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The lanes stay inside the 60% cap. `midi_editor::show` gives the roll what is left, and a + /// roll with no height returns before the lane headers are drawn, so breaking the cap would + /// leave no way to remove a lane. + #[test] + fn lane_heights_stay_inside_the_cap() { + for avail in [300.0f32, 600.0, 1200.0] { + let cap = avail * 0.6; + // Beyond this the gaps alone fill the cap; `show` shrinks the band for that case. + let max_n = ((cap - VEL_MIN) / LANE_GAP).floor().max(1.0) as usize; + for n in [1, 2, 5, 12, 25, max_n].into_iter().filter(|n| *n <= max_n) { + let (vel, lane) = lane_heights(avail, n); + let band = vel + n as f32 * (lane + LANE_GAP); + assert!(band <= cap + 0.01, "avail {avail} n {n}: band {band} over cap {cap}"); + assert!(lane <= LANE_H, "avail {avail} n {n}: lane {lane} over {LANE_H}"); + assert!(vel <= VEL_LANE_H, "avail {avail} n {n}: velocity {vel} over {VEL_LANE_H}"); + } + } + } + + /// The case the finding names: 600 px of editor and 25 lanes used to need more than the cap. + #[test] + fn twenty_five_lanes_fit_a_600px_editor() { + let (vel, lane) = lane_heights(600.0, 25); + let band = vel + 25.0 * (lane + LANE_GAP); + assert!(band <= 360.0 + 0.01, "band {band} over the 360 px cap"); + } + + #[test] + fn no_lanes_leaves_only_the_velocity_lane() { + assert_eq!(lane_heights(600.0, 0), (VEL_LANE_H, 0.0)); + } +} From 2edf91d7433911ab95662575b539df0ed86d9299 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Wed, 28 Oct 2026 15:16:55 +0100 Subject: [PATCH 10/10] midi lane menus test: match the menu text, not the platform's arrow glyph CI on Linux announced the submenu arrow differently and the tests looked for it by name. The text is what matters; the first node carrying it is the button. --- crates/ui-egui/tests/midi_lane_menus.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/midi_lane_menus.rs b/crates/ui-egui/tests/midi_lane_menus.rs index f5f06441b..59f65c951 100644 --- a/crates/ui-egui/tests/midi_lane_menus.rs +++ b/crates/ui-egui/tests/midi_lane_menus.rs @@ -33,18 +33,21 @@ fn lanes(h: &Harness) -> Vec { h.state().engine.session().tracks[0].midi_lanes.clone() } +// A menu button announces a trailing arrow glyph after its text, and which glyph it is depends on +// the fonts the platform has: match the text and let the arrow follow whatever it is. A button also +// exposes an inner label node, so the first match is taken. fn open(h: &mut Harness, header: &str) { - h.get_by_label(header).click(); + h.query_all_by_label_contains(header).next().expect("the menu opens").click(); h.run_steps(3); } fn hover(h: &mut Harness, item: &str) { - h.get_by_label(item).hover(); + h.query_all_by_label_contains(item).next().expect("the item is in the menu").hover(); h.run_steps(3); } fn pick(h: &mut Harness, item: &str) { - h.get_by_label(item).click(); + h.query_all_by_label_contains(item).next().expect("the item is in the menu").click(); h.run_steps(3); } @@ -52,7 +55,7 @@ fn pick(h: &mut Harness, item: &str) { fn the_velocity_header_adds_a_lane() { let mut h = harness(&[]); open(&mut h, "Velocity lane"); - hover(&mut h, "Add Controller Lane ⏵"); + hover(&mut h, "Add Controller Lane"); pick(&mut h, "Mod Wheel (CC 1)"); assert_eq!(lanes(&h), vec![CtrlKind::Cc(1)]); assert!(h.query_by_label("Mod Wheel (CC 1) lane").is_some(), "the new lane has a header"); @@ -83,8 +86,8 @@ fn a_lane_header_changes_its_controller() { fn a_lane_header_reaches_every_cc_number() { let mut h = harness(&["cc1"]); open(&mut h, "Mod Wheel (CC 1) lane"); - hover(&mut h, "Other Controllers ⏵"); - hover(&mut h, "CC 0–31 ⏵"); + hover(&mut h, "Other Controllers"); + hover(&mut h, "CC 0–31"); pick(&mut h, "CC 3"); assert_eq!(lanes(&h), vec![CtrlKind::Cc(3)]); }