Merge pull request #296 from grovebit/new-midi-clip

Create MIDI clips with the Pencil, Consolidate and the MIDI Editor
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:30:57 -04:00
commit cc1bf5a62d
6 files changed
+271 -3

No files matched your search

+48 -1
View File
@@ -910,18 +910,20 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
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<_>>(), 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<_>>(), 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<ClipId>) {
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
+29
View File
@@ -159,6 +159,35 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
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<Vec<ClipId>, &'static str> {
+38 -1
View File
@@ -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<Pos2>) {
}
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(&param);
@@ -2235,6 +2241,30 @@ fn spot_or_run(app: &mut SoundApp, spot: Option<Spot>) {
}
}
/// 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<Range> {
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<ClipId>) {
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)
+1
View File
@@ -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 },
}
+18 -1
View File
@@ -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 };
+137
View File
@@ -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<Event>) {
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::<Rect>(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));
}