collective

Browse / github.com/storytold/soundcraft / patch

Keep MIDI note spacing when moving notes left past tick 0

Keep MIDI note spacing when moving notes left past tick 0 Found while testing `crates/engine/src/cmd/midi.rs:128-131 (notes_move)` (no existing issue): Moving several MIDI notes left past tick 0 squeezes them together. ## Cause midi.notes_move added the tick offset to each selected note and clamped each start to 0 on its own. Notes that would land before tick 0 piled up there, so the gaps between them shrank. ## Fix notes_move now clamps the shared offset so the earliest selected note stops at tick 0, and the selection keeps its spacing. The clamp only applies to a left move that would overflow; zero and positive offsets are not changed. The regres

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #1138, open)
Base
60087e0f46032118bf309ed9ea161a9b4a872713
Size
+36 −0 in 1 file
Review
pending
Shared
2026-10-28
Id
9816ed5e8a368c1a6a07fec5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/midi.rs

diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs--- a/crates/engine/src/cmd/midi.rs+++ b/crates/engine/src/cmd/midi.rs@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {     let dt = i64_or(p, "ticks", 0);     let ds = i64_or(p, "semitones", 0);     let seq = seq_mut(e, id, "midi.notes_move")?;+    // Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.+    let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();+    let dt = match earliest {+        Some(m) => dt.max(0i64.saturating_sub(m).min(0)),+        None => dt,+    };     for (i, n) in seq.notes.iter_mut().enumerate() {         if idx.contains(&i) {             n.start = n.start.saturating_add(dt).max(0);@@ -168,6 +174,36 @@ mod tests {         assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());     } +    #[test]+    fn notes_move_left_past_zero_keeps_spacing() {+        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();+        for s in [100, 200, 300] {+            e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();+        }+        e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();+        let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();+        let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();+        assert_eq!(starts, [0, 100, 300]);+    }++    #[test]+    fn notes_move_without_left_overflow_does_not_shift_selection() {+        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 s in [0, 100] {+            e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();+        }+        seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;+        e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();+        let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();+        let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();+        assert_eq!(starts, [0, 100]);+    }+     #[test]     fn notes_move_huge_params_do_not_overflow() {         let mut e = Engine::default();