diff --git a/ROADMAP.md b/ROADMAP.md index 8ff79f111..27b1b54ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -155,6 +155,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/ diff --git a/contributors/people.toml b/contributors/people.toml index ab7b9de3d..5343f733c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -51,6 +51,9 @@ sync_github_name = false [people.Momentum96] sync_github_name = true +[people.justinjohn0306] +sync_github_name = true + [people.marcoarnulfo] real_name = "Marco Arnulfo" display_name = "Marco Arnulfo" diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b943c5c1f..d489f9b8c 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -21,6 +21,7 @@ mod file; mod health; mod instrument; mod midi; +mod midi_ctrl; mod mix; mod more_util; mod options; @@ -145,6 +146,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/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 0eb61cbcb..f716dfb5c 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -22,11 +22,20 @@ 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 + ), ] } -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")), @@ -100,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() } @@ -123,26 +132,68 @@ 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) { + let mut m = *n; + m.start = m.start.saturating_add(dt).max(0); + m.pitch = i64::from(m.pitch).saturating_add(ds).clamp(0, 127) as u8; + if !copy { + *n = m; + } + moved.push(m); + } + } + if copy { + seq.notes.extend_from_slice(&moved); + } + seq.sort(); + // 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, 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); + 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) { - n.start = n.start.saturating_add(dt).max(0); - n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8; + // 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); } } - let moved: Vec = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect(); seq.sort(); - // Where the moved notes ended up after sorting, so the editor can keep them selected. + 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)] @@ -168,6 +219,72 @@ mod tests { assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); } + #[test] + fn notes_move_reports_where_the_moved_notes_went() { + let mut e = Engine::default(); + 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 start in [0, 480, 960] { + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap(); + } + let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap(); + assert_eq!(r["indices"], json!([1])); + let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); + 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], (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] fn notes_move_huge_params_do_not_overflow() { let mut e = Engine::default(); @@ -183,20 +300,6 @@ mod tests { assert_eq!(n["notes"][0]["pitch"], 127); } - #[test] - fn notes_move_reports_where_the_moved_notes_went() { - let mut e = Engine::default(); - 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 start in [0, 480, 960] { - e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap(); - } - let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap(); - assert_eq!(r["indices"], json!([1])); - let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); - assert_eq!(notes["notes"][1]["start"], 720); - } - #[test] fn rejects_audio_tracks() { let mut e = Engine::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/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/mix/src/lib.rs b/crates/mix/src/lib.rs index b32202a0b..c7fdec241 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -229,6 +229,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() { @@ -349,7 +356,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 { diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs index 45565feba..5fafec444 100644 --- a/crates/mix/src/strip.rs +++ b/crates/mix/src/strip.rs @@ -232,7 +232,6 @@ pub(crate) 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() { @@ -294,10 +293,13 @@ pub(crate) fn process_strip( apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } inst.plugin.set_transport(&transport); - if std::mem::take(&mut strip.chase) { - chase_controllers(s, t, pos, inst); + // Stopped: no clips, but the instrument still plays auditioned notes. + if !live.monitor_only { + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } + schedule_notes(s, t, pos, frames, inst, &mut strip.held); } - 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() { diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs index 5ebc6db18..2fd887199 100644 --- a/crates/mix/src/tests.rs +++ b/crates/mix/src/tests.rs @@ -61,6 +61,33 @@ fn stored_instrument_params_reach_the_plugin() { 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/model/src/session.rs b/crates/model/src/session.rs index 5283ff831..3dedbc3a0 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(ToString::to_string) .collect(), values: std::collections::BTreeMap::new(), + pencil_shape: soundcraft_midi::PencilShape::FreeHand, } } } @@ -612,6 +616,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())); } @@ -706,6 +726,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; @@ -905,6 +927,38 @@ mod tests { 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); + } + #[test] fn a_loaded_auto_name_scheme_leaves_room_for_its_number() { let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() }; diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index 663a6a4ba..3904e524e 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -290,6 +290,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 { @@ -329,6 +332,7 @@ impl Track { ticks_timebase: kind.is_midi(), elastic: None, pinned: false, + midi_lanes: Vec::new(), } } diff --git a/crates/playback/src/callback.rs b/crates/playback/src/callback.rs index fc89c16e7..78284329e 100644 --- a/crates/playback/src/callback.rs +++ b/crates/playback/src/callback.rs @@ -76,6 +76,9 @@ pub(crate) struct AudioState { pub(crate) 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>>, @@ -121,6 +124,8 @@ impl AudioState { device_rate, input: None, recording: false, + live_notes: 0, + live_tail: 0, unsent: MeterSnapshot::default(), forget_shared: Vec::new(), } @@ -177,6 +182,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; @@ -272,7 +287,9 @@ impl AudioState { pub(crate) 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); self.unsent.silence(); @@ -285,6 +302,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 { diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 14c80c122..3cef7d249 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -126,6 +126,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, + }, /// Tracks the latest session no longer has (worked out on the UI thread): their meters go. Forget(Vec), } @@ -480,6 +486,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/lib.rs b/crates/ui-egui/src/lib.rs index a84be7e4b..c21e1e21d 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 import; pub mod menus; +pub mod midi_lanes; pub mod mix_window; pub mod palette; pub mod panels; diff --git a/crates/ui-egui/src/midi_lanes.rs b/crates/ui-egui/src/midi_lanes.rs new file mode 100644 index 000000000..8e4585dc1 --- /dev/null +++ b/crates/ui-egui/src/midi_lanes.rs @@ -0,0 +1,613 @@ +//! 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 { + // 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; + 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) + } +} + +/// 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.scope_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, full_name: &str, salt: (u64, usize)| -> Response { + let resp = ui.interact(r, ui.scope_id().with(("lane_header", salt)), Sense::click()); + let label = format!("{full_name} lane"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::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); + 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", "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(), &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. + 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(); + } + }); + } +} + +#[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)); + } +} diff --git a/crates/ui-egui/src/panels/midi_editor.rs b/crates/ui-egui/src/panels/midi_editor.rs index 2515dbbbd..211edc32b 100644 --- a/crates/ui-egui/src/panels/midi_editor.rs +++ b/crates/ui-egui/src/panels/midi_editor.rs @@ -1,46 +1,210 @@ -//! The MIDI Editor: a piano roll with 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, 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}; -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 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 VEL_H: f32 = 64.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, + /// 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 { - /// 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.ctrl_selected.clear(); + self.lane_gesture = 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. +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() { + 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. +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) } /// The MIDI clip being edited: the first selected MIDI clip, else a MIDI clip under the insertion @@ -68,8 +232,57 @@ 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)); +} + +/// 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; + let combo = + craft_ui::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()})); + } + } + }); + // The button shows the shape's name; a screen reader (and kittest) needs one for the control. + combo.response.accessible_name("Pencil shape"); +} + /// Draw the MIDI editor for the target clip, with an empty-state prompt that adds a clip on the selected MIDI track. 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(); @@ -80,6 +293,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())) @@ -103,11 +317,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() @@ -129,7 +344,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; @@ -145,6 +360,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); @@ -153,113 +369,297 @@ 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)); + // 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 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 + 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) { + 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; + } + 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)); } } - // 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) }), - ); + // 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; + } + } + 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 + }; + 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 { + 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); + 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 { + 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.scope_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.scope_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, full.max.y - 2.0)], Stroke::new(w, col)); } // Interaction. - let resp = ui.interact(roll, ui.scope_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 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; the Pencil plays the note + // it is drawing. + let key_resp = ui.interact(keys, ui.scope_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| (rv.pitch_at(p.y).clamp(0, 127) as u8, 100)) + } else if resp.is_pointer_button_down_on() { + 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 }; - let snap = |tk: f32| -> i64 { ((tk / grid_ticks as f32).round() as i64) * grid_ticks }; + 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]; + app.midi.ctrl_selected.clear(); + 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, @@ -268,58 +668,112 @@ 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() { + app.midi.ctrl_selected.clear(); 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 { app.midi.selected = vec![i]; } - // Audition on the instrument would go here (needs a live MIDI input path). } 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}), @@ -329,6 +783,24 @@ 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) { + 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() { let (all, up, down, left, right, shift) = ui.input(|i| { @@ -363,34 +835,113 @@ 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() + 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(); } - // Velocity lane. - ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30)); - 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.scope_id().with(("vel", cid.0)), Sense::click_and_drag()); - for (i, n) in sequence.notes.iter().enumerate() { - let x = x_of(n.start); + 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); + } + 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. +/// 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)); + 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.scope_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() { + 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)); + } } } @@ -408,4 +959,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/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index cc063c684..b56ce920b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -187,9 +187,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/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()); +} 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..59f65c951 --- /dev/null +++ b/crates/ui-egui/tests/midi_lane_menus.rs @@ -0,0 +1,101 @@ +//! 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() +} + +// 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.query_all_by_label_contains(header).next().expect("the menu opens").click(); + h.run_steps(3); +} + +fn hover(h: &mut Harness, item: &str) { + 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.query_all_by_label_contains(item).next().expect("the item is in the menu").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)]); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 23c4b54a5..70f4624e3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (MIDI Editor controller lanes and the Pencil’s shapes; Tracks and Groups and Clips in dock columns; previously: Scroll Into View in the track name menu; 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`); Tracks and Groups and the Clip List sit in full-height dock columns at the window's sides | | 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-27 | 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-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged | | 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) |