Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fb672fa
MIDI Editor: holding a piano key or a note plays it on the track's in…
justinjohn0306 Oct 13, 2026
17744e8
Credit justinjohn0306 by GitHub profile name
justinjohn0306 Oct 13, 2026
ba7c7f0
Merge main into #419
justinjohn0306 Oct 13, 2026
5acb7ab
Merge branch 'pr-521' into midi-editor-upgrade
Limestudio90 Oct 13, 2026
6160c71
MIDI Editor: zoom, ruler and playhead; Pencil drags, group trims, Alt…
Limestudio90 Oct 13, 2026
7e0fddc
ROADMAP: MIDI Editor navigation and note gestures
Limestudio90 Oct 13, 2026
42addb5
Merge remote-tracking branch 'origin/main' into midi-editor-upgrade
Limestudio90 Oct 15, 2026
1a6a6ea
Merge remote-tracking branch 'origin/main' into midi-editor-upgrade
Limestudio90 Oct 19, 2026
115496f
midi.notes_trim: an end trim past the start keeps the note where it is
Limestudio90 Oct 19, 2026
56bfb63
Merge remote-tracking branch 'origin/main' into midi-editor-upgrade
Limestudio90 Oct 21, 2026
c784821
MIDI Editor: controller lanes per track and the Pencil's shapes
Limestudio90 Oct 21, 2026
a42397b
MIDI Editor: lane headers are named buttons; tests for their menus
Limestudio90 Oct 21, 2026
f32e745
Session: sanitize midi_lanes on load, as midi.lanes does
Limestudio90 Oct 21, 2026
61b17a4
MIDI Editor: lane heights keep the roll, so lane headers stay reachable
Limestudio90 Oct 21, 2026
83ff525
Merge main into midi-editor-upgrade
Limestudio90 Oct 24, 2026
c16baee
Merge main into midi-editor-upgrade
Limestudio90 Oct 27, 2026
2edf91d
midi lane menus test: match the menu text, not the platform's arrow g…
Limestudio90 Oct 28, 2026
5fc5087
Merge remote-tracking branch 'origin/main' into midi-editor-upgrade
Limestudio90 Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-12 (plugin editors on Windows):** VST3 editors and embedded CLAP editors open in a SoundCraft host window on Windows (owned by the app window, resized on request, closed from its title bar); verified with a JUCE VST3 compressor and a CLAP effect. No readiness percentage change.
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-13 (MIDI Editor navigation and note gestures):** the MIDI Editor zooms (Ctrl/Cmd+wheel for time, Alt+wheel for row height) and scrolls, shows a bars ruler and the playhead, and draws grid, beat and bar lines on the session timeline. The Pencil draws a note as long as the drag and plays it, trimming one selected note trims them all, Alt-drag copies, edits keep the notes selected, and snapping follows the Edit mode (Ctrl/Cmd suspends it). The velocity lane edits only the selected notes (one note of a chord) and draws across stems. No readiness percentage change.
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
Expand Down
3 changes: 3 additions & 0 deletions contributors/people.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ sync_github_name = false
[people.Momentum96]
sync_github_name = true

[people.justinjohn0306]
sync_github_name = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
Expand Down
136 changes: 132 additions & 4 deletions crates/engine/src/cmd/midi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,16 @@ pub fn specs() -> Vec<CommandSpec> {
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),
cmd!("midi.notes_move", "Move Notes", [], None, "{clip, indices: [n], ticks?: 0, semitones?: 0, copy?: false}", always, notes_move),
cmd!(
"midi.notes_trim",
"Trim Notes",
[],
None,
"{clip, indices: [n], start?: 0, end?: 0} (tick offsets for the note edges)",
always,
notes_trim
),
]
}

Expand Down Expand Up @@ -123,15 +132,68 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
let idx = indices(p);
let dt = i64_or(p, "ticks", 0);
let ds = i64_or(p, "semitones", 0);
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
let seq = seq_mut(e, id, "midi.notes_move")?;
let mut moved = Vec::with_capacity(idx.len());
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 mut m = *n;
m.start = m.start.saturating_add(dt).max(0);
m.pitch = i64::from(m.pitch).saturating_add(ds).clamp(0, 127) as u8;
if !copy {
*n = m;
}
moved.push(m);
}
}
if copy {
seq.notes.extend_from_slice(&moved);
}
seq.sort();
Ok(json!({"moved": idx.len()}))
// Where the moved notes (or the copies) ended up after sorting, so the editor can keep them selected.
Ok(json!({"moved": moved.len(), "indices": relocate(seq, &moved)}))
}

/// Trim the start and/or end of several notes at once: `start` and `end` are tick offsets for the
/// note edges (a start trim keeps the note's end in place, an end trim its start). Notes never
/// shrink below one tick.
fn notes_trim(e: &mut Engine, p: &Value) -> Result<Value> {
let id = clip_id(p, "midi.notes_trim")?;
let idx = indices(p);
let ds = i64_or(p, "start", 0);
let de = i64_or(p, "end", 0);
let seq = seq_mut(e, id, "midi.notes_trim")?;
let mut trimmed = Vec::with_capacity(idx.len());
for (i, n) in seq.notes.iter_mut().enumerate() {
if idx.contains(&i) {
// The start is limited by the old end and the end by the new start, so a trim past the
// other edge leaves a one-tick note there instead of moving it.
let old_end = n.end();
let start = n.start.saturating_add(ds).clamp(0, (old_end - 1).max(0));
let end = old_end.saturating_add(de).max(start + 1);
n.start = start;
n.length = end - start;
trimmed.push(*n);
}
}
seq.sort();
Ok(json!({"trimmed": trimmed.len(), "indices": relocate(seq, &trimmed)}))
}

/// The sorted indices `notes` now sit at in `seq` (each matched once, so duplicates stay distinct).
fn relocate(seq: &Sequence, notes: &[Note]) -> Vec<usize> {
let mut taken = vec![false; seq.notes.len()];
let mut out = Vec::with_capacity(notes.len());
for m in notes {
if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken.get(j).copied().unwrap_or(true) && n == m) {
if let Some(t) = taken.get_mut(j) {
*t = true;
}
out.push(j);
}
}
out.sort_unstable();
out
}

#[cfg(test)]
Expand All @@ -157,6 +219,72 @@ mod tests {
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
}

#[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);
}

fn keys_clip(e: &mut Engine, notes: &[(u8, i64, i64)]) -> u64 {
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 &(pitch, start, length) in notes {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": length})).unwrap();
}
clip
}

fn starts_pitches_lengths(e: &mut Engine, clip: u64) -> Vec<(i64, i64, i64)> {
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
n["notes"]
.as_array()
.unwrap()
.iter()
.map(|n| (n["start"].as_i64().unwrap(), n["pitch"].as_i64().unwrap(), n["length"].as_i64().unwrap()))
.collect()
}

#[test]
fn notes_move_with_copy_leaves_the_originals_and_reports_the_copies() {
let mut e = Engine::default();
let clip = keys_clip(&mut e, &[(60, 0, 240), (64, 480, 240)]);
let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 960, "semitones": 2, "copy": true})).unwrap();
assert_eq!(r["indices"], json!([2, 3]));
assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(0, 60, 240), (480, 64, 240), (960, 62, 240), (1440, 66, 240)]);
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(starts_pitches_lengths(&mut e, clip).len(), 2, "one undo step removes the copies");
}

#[test]
fn notes_trim_moves_both_edges_of_every_note_in_one_step() {
let mut e = Engine::default();
let clip = keys_clip(&mut e, &[(60, 480, 480), (64, 960, 240)]);
let r = e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0, 1], "end": 120})).unwrap();
assert_eq!(r["indices"], json!([0, 1]));
assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(480, 60, 600), (960, 64, 360)]);
// A start trim keeps the end; it stops one tick short of it and never goes below zero.
e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0, 1], "start": -600})).unwrap();
assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(0, 60, 1080), (360, 64, 960)]);
e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [1], "start": 5000, "end": -5000})).unwrap();
assert_eq!(starts_pitches_lengths(&mut e, clip)[1], (1319, 64, 1));
}

#[test]
fn notes_trim_past_the_start_keeps_the_note_where_it_is() {
let mut e = Engine::default();
let clip = keys_clip(&mut e, &[(60, 960, 120)]);
e.execute("midi.notes_trim", &json!({"clip": clip, "indices": [0], "end": -240})).unwrap();
assert_eq!(starts_pitches_lengths(&mut e, clip), vec![(960, 60, 1)], "like the editor's end-trim preview");
}

#[test]
fn notes_move_huge_params_do_not_overflow() {
let mut e = Engine::default();
Expand Down
47 changes: 42 additions & 5 deletions crates/mix/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,13 @@ impl MixEngine {
self.main.len()
}

/// Play (velocity > 0) or release (velocity 0) `pitch` on a track's instrument at the next
/// block, outside the clips: the MIDI Editor's audition. Does nothing before `sync`.
pub fn live_note(&mut self, track: TrackId, pitch: u8, velocity: u8) {
let Some(inst) = self.strips.get_mut(&track).and_then(|s| s.instrument.as_mut()) else { return };
if velocity > 0 { inst.plugin.note_on(0, pitch.min(127), velocity.min(127)) } else { inst.plugin.note_off(0, pitch.min(127)) }
}

/// Reset all plugin state (on stop / seek).
pub fn reset(&mut self) {
for s in self.strips.values_mut() {
Expand Down Expand Up @@ -615,7 +622,8 @@ impl MixEngine {
/// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).
pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {
let frames = frames.min(self.max_block);
if pos != self.last_end {
// Stopped, the position stands still: not a seek (it would cut auditioned notes).
if pos != self.last_end && !self.monitor_only {
// Seek: silence held notes and ringing plugins.
for st in self.strips.values_mut() {
if let Some(i) = &mut st.instrument {
Expand Down Expand Up @@ -932,7 +940,6 @@ fn process_strip(
}
}
TrackKind::Audio if live.monitor_only => {}
TrackKind::Instrument | TrackKind::Midi if live.monitor_only => {}
TrackKind::Audio => {
let block = Range { start: pos, end: pos.saturating_add(frames as i64) };
for clip in t.clips() {
Expand Down Expand Up @@ -994,10 +1001,13 @@ fn process_strip(
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
}
inst.plugin.set_transport(&transport);
if std::mem::take(&mut strip.chase) {
chase_controllers(s, t, pos, inst);
// Stopped: no clips, but the instrument still plays auditioned notes.
if !live.monitor_only {
if std::mem::take(&mut strip.chase) {
chase_controllers(s, t, pos, inst);
}
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
}
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
if strip.buf.len() >= 2 {
inst.plugin.process(&mut strip.buf, frames);
} else if let Some(c0) = strip.buf.first_mut() {
Expand Down Expand Up @@ -2317,6 +2327,33 @@ mod tests {
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
}

#[test]
fn live_notes_sound_while_stopped_without_the_clips() {
let s = synth_session(-6.0);
let t = s.tracks[0].id;
let mut eng = MixEngine::new(48_000.0, 512);
eng.monitor_only = true;
let mut out = vec![vec![0.0f32; 512]; 2];
let peak = |out: &[Vec<f32>]| out.iter().flatten().fold(0.0f32, |m, x| m.max(x.abs()));
let mut loudest = 0.0f32;
for _ in 0..8 {
eng.render(&s, 0, 512, &mut out);
loudest = loudest.max(peak(&out));
}
assert_eq!(loudest, 0.0, "stopped: the clip's note must not play");
eng.live_note(t, 72, 100);
for _ in 0..8 {
eng.render(&s, 0, 512, &mut out);
loudest = loudest.max(peak(&out));
}
assert!(loudest > 0.01, "the auditioned note should sound: {loudest}");
eng.live_note(t, 72, 0);
for _ in 0..400 {
eng.render(&s, 0, 512, &mut out);
}
assert!(peak(&out) < 1e-4, "the released note should die away: {}", peak(&out));
}

#[test]
fn insert_slot_a_automation_does_not_drive_the_instrument() {
let mut s = synth_session(-6.0);
Expand Down
34 changes: 33 additions & 1 deletion crates/playback/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ enum Cmd {
},
Stop,
Locate(Samples),
/// Audition a note on a track's instrument (velocity 0 releases it).
Note {
track: TrackId,
pitch: u8,
velocity: u8,
},
}

struct Shared {
Expand Down Expand Up @@ -168,6 +174,9 @@ struct AudioState {
device_rate: f64,
input: Option<Arc<record::InputRing>>,
recording: bool,
/// Auditioned notes held, and the frames left to render the last one's release.
live_notes: usize,
live_tail: usize,
/// Retired plugin instances go back to the UI thread to be dropped there.
garbage: Sender<Vec<Box<dyn Plugin>>>,
old_sessions: Option<SyncSender<Arc<Session>>>,
Expand Down Expand Up @@ -202,6 +211,8 @@ impl AudioState {
device_rate,
input: None,
recording: false,
live_notes: 0,
live_tail: 0,
}
}

Expand Down Expand Up @@ -247,6 +258,16 @@ impl AudioState {
self.looped = None;
self.mix.reset();
}
Cmd::Note { track, pitch, velocity } => {
self.mix.live_note(track, pitch, velocity);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if velocity > 0 {
self.live_notes = self.live_notes.saturating_add(1);
} else {
self.live_notes = self.live_notes.saturating_sub(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// Long enough for a synth's release to ring out.
self.live_tail = (self.device_rate * 2.0) as usize;
}
}
Cmd::Locate(at) => {
self.pos = at;
self.pending_len = 0;
Expand Down Expand Up @@ -331,7 +352,9 @@ impl AudioState {
fn fill(&mut self, out: &mut [f32], channels: usize) {
self.drain();
let channels = channels.max(1);
let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm);
let monitoring = self.live_notes > 0
|| self.live_tail > 0
|| self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm);
if !self.playing && !monitoring {
out.iter_mut().for_each(|x| *x = 0.0);
if let Ok(mut m) = self.shared.meters.try_lock() {
Expand All @@ -343,6 +366,9 @@ impl AudioState {
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
let callback_frames = out.len() / channels;
if self.live_notes == 0 {
self.live_tail = self.live_tail.saturating_sub(callback_frames);
}
let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
for frame in out.chunks_mut(channels) {
while self.read >= self.pending_len as f64 {
Expand Down Expand Up @@ -759,6 +785,12 @@ impl Player {
let _ = self.tx.send(Cmd::Recording(on));
}

/// Play `pitch` on `track`'s instrument while the transport is stopped or playing, until a
/// call with velocity 0 releases it (the MIDI Editor's audition).
pub fn note(&self, track: TrackId, pitch: u8, velocity: u8) {
let _ = self.tx.send(Cmd::Note { track, pitch, velocity });
}

pub fn stop(&self) {
self.shared.playing.store(false, Ordering::Relaxed);
let _ = self.tx.send(Cmd::Stop);
Expand Down
15 changes: 15 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,10 @@ impl SoundApp {
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
match self.engine.execute(id, &params) {
Ok(v) => {
if replacing {
// Note indices and gestures belong to the old session's clips.
self.midi = midi_editor::MidiEditorState::default();
}
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
self.release_autosave(false);
}
Expand Down Expand Up @@ -1370,6 +1374,17 @@ mod tests {
assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None);
}

#[test]
fn replacing_the_session_drops_the_midi_note_selection() {
for command in ["session.new", "session.close"] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.midi.selected = vec![0, 1];
app.midi.clip = Some(soundcraft_model::ClipId(1));
app.run(command, json!({})).unwrap();
assert!(app.midi.selected.is_empty() && app.midi.clip.is_none(), "{command}");
}
}

#[test]
fn new_session_cancels_play_queued_before_the_next_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
Expand Down
Loading
Loading