//! MIDI note editing (MIDI Editor, pencil on MIDI tracks). use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required}; use crate::{Engine, Result, cmd}; use serde_json::{Value, json}; use soundcraft_midi::{Note, Sequence}; use soundcraft_model::{Clip, ClipContent, ClipId}; use soundcraft_time::TICKS_PER_QUARTER; pub fn specs() -> Vec { vec![ cmd!(query "midi.notes", "List MIDI Notes", [], None, "{clip}", always, notes), cmd!( "midi.new_clip", "New MIDI Clip", ["Clip"], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, new_clip ), 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), ] } 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")), None => Err(bad(cmd, "no such clip")), } } fn notes(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.notes")?; match e.session().find_clip(id).map(|(_, c)| &c.content) { Some(ClipContent::Midi { sequence }) => Ok(json!({"notes": sequence.notes})), _ => Err(bad("midi.notes", "not a MIDI clip")), } } fn new_clip(e: &mut Engine, p: &Value) -> Result { let t = tracks_required(e, "midi.new_clip", p)?.first().copied().ok_or_else(|| bad("midi.new_clip", "no track"))?; if !e.session().track(t).is_some_and(|x| x.kind.is_midi()) { return Err(bad("midi.new_clip", "not a MIDI or instrument track")); } let mut r = range_param(e, "midi.new_clip", p)?; if r.is_empty() { let s = e.session(); let bar = soundcraft_time::GridValue::Note { value: soundcraft_time::NoteValue::Bar, dotted: false, triplet: false }; let start = bar.line_at_or_before(r.start, s.sample_rate, &s.tempo, s.frame_rate); r = soundcraft_time::Range::new(start, bar.next_line(start, s.sample_rate, &s.tempo, s.frame_rate)); } let s = e.session_mut(); let id = s.new_clip_id(); let name = s.track(t).map(|x| x.name.clone()).unwrap_or_default(); crate::edit::place_clip(s, t, Clip::midi(id, name, r.start, r.len(), Sequence::default())); Ok(json!({"clip": id, "start": r.start, "end": r.end})) } fn note_add(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.note_add")?; let n = Note { pitch: i64_or(p, "pitch", 60).clamp(0, 127) as u8, velocity: i64_or(p, "velocity", 100).clamp(1, 127) as u8, release_velocity: 64, channel: i64_or(p, "channel", 0).clamp(0, 15) as u8, start: i64_or(p, "start_ticks", 0).max(0), length: i64_or(p, "length_ticks", TICKS_PER_QUARTER / 2).max(1), }; let seq = seq_mut(e, id, "midi.note_add")?; seq.notes.push(n); seq.sort(); let index = seq.notes.iter().position(|x| *x == n).unwrap_or(0); Ok(json!({"index": index, "notes": seq.notes.len()})) } fn note_edit(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.note_edit")?; let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("midi.note_edit", "`index` required"))? as usize; let seq = seq_mut(e, id, "midi.note_edit")?; let n = seq.notes.get_mut(idx).ok_or_else(|| bad("midi.note_edit", "index out of range"))?; if let Some(v) = p.get("pitch").and_then(Value::as_i64) { n.pitch = v.clamp(0, 127) as u8; } if let Some(v) = p.get("start_ticks").and_then(Value::as_i64) { n.start = v.max(0); } if let Some(v) = p.get("length_ticks").and_then(Value::as_i64) { n.length = v.max(1); } if let Some(v) = p.get("velocity").and_then(Value::as_i64) { n.velocity = v.clamp(1, 127) as u8; } let edited = *n; seq.sort(); Ok(json!({"note": edited, "index": seq.notes.iter().position(|x| *x == edited)})) } 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() } fn note_delete(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.note_delete")?; let idx = indices(p); let seq = seq_mut(e, id, "midi.note_delete")?; let before = seq.notes.len(); let mut i = 0; seq.notes.retain(|_| { let keep = !idx.contains(&i); i += 1; keep }); Ok(json!({"deleted": before - seq.notes.len()})) } fn notes_move(e: &mut Engine, p: &Value) -> Result { let id = clip_id(p, "midi.notes_move")?; let idx = indices(p); let dt = i64_or(p, "ticks", 0); let ds = i64_or(p, "semitones", 0); let seq = seq_mut(e, id, "midi.notes_move")?; 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; } } 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. 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); } } indices.sort_unstable(); Ok(json!({"moved": idx.len(), "indices": indices})) } #[cfg(test)] mod tests { use super::*; use soundcraft_model::{ChannelFormat, TrackKind}; #[test] fn create_clip_add_edit_move_delete() { let mut e = Engine::default(); let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap(); let clip = r["clip"].as_u64().unwrap(); e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0})).unwrap(); e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 480})).unwrap(); e.execute("midi.note_edit", &json!({"clip": clip, "index": 0, "velocity": 30})).unwrap(); e.execute("midi.notes_move", &json!({"clip": clip, "indices": [1], "semitones": 3})).unwrap(); let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); assert_eq!(n["notes"][0]["velocity"], 30); assert_eq!(n["notes"][1]["pitch"], 67); e.execute("midi.note_delete", &json!({"clip": clip, "indices": [0]})).unwrap(); assert_eq!(e.execute("midi.notes", &json!({"clip": clip})).unwrap()["notes"].as_array().map(Vec::len), Some(1)); assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err()); } #[test] fn notes_move_huge_params_do_not_overflow() { let mut e = Engine::default(); let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap(); let clip = r["clip"].as_u64().unwrap(); e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap(); e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap(); let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); assert_eq!(n["notes"][0]["start"], i64::MAX); e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap(); let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap(); 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(); let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None); assert!(e.execute("midi.new_clip", &json!({"track": t.0})).is_err()); } }