diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a46e20..3dbb6b0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -910,18 +910,20 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { if r.is_empty() { return Err(bad("edit.consolidate", "selection is empty")); } + let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count(); let mut n = 0; for t in tracks { if crate::io::consolidate(e, "edit.consolidate", t, r)? { n += 1; } } - Ok(json!({"consolidated": n})) + Ok(json!({"consolidated": n + midi})) } #[cfg(test)] mod tests { use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind}; #[test] fn selecting_what_is_already_selected_leaves_the_document_alone() { @@ -936,6 +938,51 @@ mod tests { assert_ne!(e.revision, rev); } + fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate); + (e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr)) + } + + fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note { + let q = soundcraft_time::TICKS_PER_QUARTER; + soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q } + } + + #[test] + fn consolidating_an_empty_midi_selection_makes_an_empty_clip() { + let (mut e, t, at) = keys(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap(); + assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(at(4), at(12))]); + assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + } + + #[test] + fn consolidating_midi_merges_the_clips_as_they_sound() { + let (mut e, t, at) = keys(); + let s = e.session_mut(); + let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 }; + let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] }; + let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] }; + let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] }; + for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] { + let id = s.new_clip_id(); + let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq); + clip.muted = muted; + ops::place_clip(s, t, clip); + } + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap(); + e.execute("edit.consolidate", &json!({})).unwrap(); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]); + let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") }; + assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]); + assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]); + } + /// A 60,000-frame source with `clips` placed on one track as (start, length, source offset). fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec) { use soundcraft_model::{ChannelFormat, Clip, TrackKind}; diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 5fb435c..55fbfbf 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -159,6 +159,35 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { out } +/// Merge the MIDI inside `range` into one clip covering the whole range, gaps included, as it +/// sounds (muted clips stay silent). Returns false for tracks that aren't MIDI or instrument tracks. +pub fn consolidate_midi(s: &mut Session, track: TrackId, range: Range) -> bool { + let Some(name) = s.track(track).filter(|t| t.kind.is_midi()).map(|t| t.name.clone()) else { return false }; + let mut merged = soundcraft_midi::Sequence::default(); + { + let t = TimeCtx::of(s); + let base = t.ticks(range.start); + for part in copy_range(s, track, range).into_iter().filter(|c| !c.muted) { + let at = t.ticks(range.start.saturating_add(part.start)).saturating_sub(base); + let len = t.ticks(range.start.saturating_add(part.end())).saturating_sub(base).saturating_sub(at); + let ClipContent::Midi { sequence } = part.content else { continue }; + for mut n in sequence.notes.into_iter().filter(|n| (0..len).contains(&n.start)) { + n.length = n.length.min(len - n.start); + n.start += at; + merged.notes.push(n); + } + for mut c in sequence.ctrls.into_iter().filter(|c| (0..len).contains(&c.tick)) { + c.tick += at; + merged.ctrls.push(c); + } + } + } + merged.sort(); + let id = s.new_clip_id(); + place_clip(s, track, Clip::midi(id, name, range.start, range.len(), merged)); + true +} + /// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless /// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns). pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result, &'static str> { diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index b5209d2..fa96d95 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1928,6 +1928,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), + Tool::Pencil if auto_view.is_none() && track.kind.is_midi() => { + hit.is_none().then_some(Gesture::MidiClip { track: track.id, from: at_origin, to: at_origin }) + } Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; @@ -1986,6 +1989,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, "automation.set_point", json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}), ); + } else if track.kind.is_midi() && hit.is_none() { + new_midi_clip(app, &s, track.id, at, at); } } _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), @@ -2051,7 +2056,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { *delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command)); } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)), - Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::Fade { to, .. } | Gesture::MidiClip { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -2108,6 +2113,7 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { } Some(Gesture::TrimStart { clip, to, .. }) => trim_clip(app, clip, to, true), Some(Gesture::TrimEnd { clip, to, .. }) => trim_clip(app, clip, to, false), + Some(Gesture::MidiClip { track, from, to }) => new_midi_clip(app, s, track, from, to), Some(Gesture::Pencil { track, points }) => { if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { let pid = automation_id(¶m); @@ -2235,6 +2241,30 @@ fn spot_or_run(app: &mut SoundApp, spot: Option) { } } +/// Whole bars from `from` to `to` (at least one), inside the gap between clips where `from` is. +fn midi_clip_span(s: &Session, track: TrackId, from: Samples, to: Samples) -> Option { + let clips = s.track(track)?.clips(); + let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; + let start = bar.line_at_or_before(from.min(to), s.sample_rate, &s.tempo, s.frame_rate); + let last = from.max(to); + let mut end = bar.line_at_or_before(last, s.sample_rate, &s.tempo, s.frame_rate); + if end < last || end <= start { + end = bar.next_line(end, s.sample_rate, &s.tempo, s.frame_rate); + } + let prev = clips.iter().map(Clip::end).filter(|&e| e <= from).max().unwrap_or(start); + let next = clips.iter().map(|c| c.start).filter(|&b| b > from).min().unwrap_or(end); + let r = Range::new(start.max(prev), end.min(next)); + (!r.is_empty()).then_some(r) +} + +fn new_midi_clip(app: &mut SoundApp, s: &Session, track: TrackId, from: Samples, to: Samples) { + if let Some(r) = midi_clip_span(s, track, from, to) + && let Ok(v) = app.run("midi.new_clip", json!({"track": track.0, "start": r.start, "end": r.end})) + { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } +} + fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { let Some(c) = clip else { if ui.button("Paste").clicked() { @@ -2393,6 +2423,13 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } } + if let Some(Gesture::MidiClip { track, from, to }) = &app.gesture + && let Some(r) = midi_clip_span(s, *track, *from, *to) + && let Some((_, row)) = app.edit_layout.rows.iter().find(|(i, _)| *i == track.0) + { + let ghost = Rect::from_min_max(pos2(x_of(s, tl, r.start), row[1] + 1.0), pos2(x_of(s, tl, r.end), row[3] - 1.0)); + painter.rect_stroke(ghost, 2.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Inside); + } if let Some(Gesture::TrimStart { to, clip, .. } | Gesture::TrimEnd { to, clip, .. }) = &app.gesture && let Some((tid, _)) = s.find_clip(*clip) && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d953778..1bbad22 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -227,6 +227,7 @@ pub enum Gesture { Fader { track: TrackId, start_db: f32 }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, + MidiClip { track: TrackId, from: Samples, to: Samples }, ZoomBox { start: f32 }, } diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 0d538e7..ef440d2 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -66,7 +66,24 @@ 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 { - ui.painter().text(full.center(), Align2::CENTER_CENTER, "Select a MIDI clip (or a MIDI track) to edit its notes.", regular(13.0), t.text_dim); + 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())) + }; + let msg = match &track { + Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), + None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), + }; + ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); + let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); + if let Some((id, _)) = track + && ui.interact(body, ui.id().with("new_clip"), Sense::click()).clicked() + { + let sel = app.engine.session().edit.selection; + if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } + } return; }; let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; diff --git a/crates/ui-egui/tests/new_midi_clip.rs b/crates/ui-egui/tests/new_midi_clip.rs new file mode 100644 index 0000000..ec3fb07 --- /dev/null +++ b/crates/ui-egui/tests/new_midi_clip.rs @@ -0,0 +1,137 @@ +//! Empty MIDI clips from the Pencil, in whole bars, and from the empty MIDI Editor (#282). + +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_model::ClipContent; +use soundcraft_time::BarBeat; +use soundcraft_ui_egui::{Services, SoundApp, edit_window::x_of, midi_editor}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, +} + +impl Rig { + fn new(kind: &str) -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": kind})).unwrap(); + engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()) }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + fn bar(&self, bar: i64) -> i64 { + let s = self.app.engine.session(); + s.tempo.samples_at_bar_beat(BarBeat { bar, beat: 1, tick: 0 }, s.sample_rate) + } + + fn at(&self, at: i64) -> Pos2 { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + let x = x_of(self.app.engine.session(), Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, self.app.edit_layout.rows[0].1[1] + 10.0) + } + + fn press(&mut self, at: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } + + fn drag(&mut self, from: i64, to: i64) { + let (a, b) = (self.at(from), self.at(to)); + self.press(a, true); + for k in 1..=6 { + self.frame(vec![Event::PointerMoved(a.lerp(b, k as f32 / 6.0))]); + } + self.press(b, false); + self.frames(2); + } + + fn click(&mut self, at: i64) { + self.click_at(self.at(at)); + } + + fn click_at(&mut self, p: Pos2) { + self.press(p, true); + self.press(p, false); + self.frames(2); + } + + fn clips(&self) -> Vec<(i64, i64)> { + self.app.engine.session().tracks[0].clips().iter().map(|c| (c.start, c.end())).collect() + } +} + +#[test] +fn a_pencil_click_on_an_empty_instrument_track_makes_a_one_bar_clip() { + let mut r = Rig::new("instrument"); + let mid = (r.bar(3) + r.bar(4)) / 2; + r.click(mid); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + let s = r.app.engine.session(); + let clip = &s.tracks[0].clips()[0]; + assert!(matches!(&clip.content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + assert_eq!(s.edit.selected_clips, vec![clip.id]); +} + +#[test] +fn a_pencil_drag_makes_a_clip_over_the_bars_it_crosses() { + let mut r = Rig::new("midi"); + let (from, to) = ((r.bar(2) + r.bar(3)) / 2, (r.bar(4) + r.bar(5)) / 2); + r.drag(from, to); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); + r.app.run("edit.undo", json!({})).unwrap(); + r.drag(to, from); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); +} + +#[test] +fn a_pencil_drag_stops_at_the_next_clip_and_clicks_on_clips_add_nothing() { + let mut r = Rig::new("instrument"); + r.click(r.bar(5)); + r.drag(r.bar(2), (r.bar(6) + r.bar(7)) / 2); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5)), (r.bar(5), r.bar(6))]); + r.click((r.bar(2) + r.bar(3)) / 2); + assert_eq!(r.clips().len(), 2); +} + +#[test] +fn the_pencil_makes_no_clips_on_audio_tracks() { + let mut r = Rig::new("audio"); + r.click(r.bar(3)); + r.drag(r.bar(2), r.bar(4)); + assert!(r.clips().is_empty()); +} + +#[test] +fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { + let mut r = Rig::new("instrument"); + r.app.ui.show_midi_editor = true; + let track = r.app.engine.session().tracks[0].id.0; + r.app.run("edit.select", json!({"tracks": [track], "start": (r.bar(3) + r.bar(4)) / 2})).unwrap(); + r.frames(4); + assert_eq!(midi_editor::target_clip(&r.app), None); + let body = r.ctx.data(|d| d.get_temp::(Id::new("soundcraft-midi-body"))).unwrap(); + r.click_at(body.center()); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id)); +}