Files
soundcraft/crates/engine/src/cmd/midi.rs
T
2026-10-24 06:53:56 +00:00

207 lines
9.3 KiB
Rust

//! 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<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<usize> {
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<Value> {
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<Value> {
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<Note> = 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());
}
}