Score Editor: MIDI as notation on a grand staff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 23:54:51 -04:00
co-authored by Claude Opus 5.5
parent f534ca4e15
commit 9da524fd66
3 changed files with 165 additions and 0 deletions
+4
View File
@@ -14,6 +14,7 @@ pub mod midi_editor;
pub mod mix_window;
pub mod ops_windows;
pub mod panels;
pub mod score_editor;
pub mod shortcuts;
pub mod theme;
pub mod toolbar;
@@ -80,6 +81,7 @@ pub struct UiState {
pub show_time_ops: bool,
pub show_midi_ops: bool,
pub show_rtp: bool,
pub show_score: bool,
pub workspace_dir: String,
pub configurations: Vec<(String, Value)>,
}
@@ -123,6 +125,7 @@ impl Default for UiState {
show_time_ops: false,
show_midi_ops: false,
show_rtp: false,
show_score: false,
workspace_dir: String::new(),
configurations: Vec::new(),
}
@@ -716,6 +719,7 @@ impl SoundApp {
panels::floating(self, &ctx);
windows::show(self, &ctx);
ops_windows::show(self, &ctx);
score_editor::show(self, &ctx);
dialogs::show(self, &ctx);
}
+2
View File
@@ -48,6 +48,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None),
("window.clip_effects", "Clip Effects", "Window > Clip Effects", None),
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", None),
("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")),
("window.beat_detective", "Beat Detective", "", None),
("window.tempo_ops", "Tempo Operations", "", None),
("window.time_ops", "Time Operations", "", None),
@@ -384,6 +385,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
}
"window.automation" => toggle(&mut app.ui.show_automation),
"window.clip_effects" | "window.clip_effects_dock" => toggle(&mut app.ui.show_clip_effects),
"window.score" => toggle(&mut app.ui.show_score),
"window.beat_detective" => toggle(&mut app.ui.show_beat_detective),
"window.tempo_ops" => toggle(&mut app.ui.show_tempo_ops),
"window.time_ops" => toggle(&mut app.ui.show_time_ops),
+159
View File
@@ -0,0 +1,159 @@
//! Score Editor: the selected MIDI clip (or track) as notation on a grand staff.
//!
//! A readable sketch rather than an engraving engine: note heads by duration (whole, half,
//! quarter, eighth with flags), stems, accidentals (sharps), ledger lines, bar lines from the
//! meter map, and the clef split at middle C.
use crate::SoundApp;
use crate::theme::{bold, regular};
use egui::{Align2, Color32, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use soundcraft_model::ClipContent;
use soundcraft_time::TICKS_PER_QUARTER;
const STEP: f32 = 6.0; // half the distance between staff lines
/// Diatonic step of a MIDI pitch (C4 = 60 → 0) and whether it needs a sharp.
fn diatonic(pitch: u8) -> (i32, bool) {
let p = i32::from(pitch);
let oct = p.div_euclid(12) - 5;
let (step, sharp) = match p.rem_euclid(12) {
0 => (0, false),
1 => (0, true),
2 => (1, false),
3 => (1, true),
4 => (2, false),
5 => (3, false),
6 => (3, true),
7 => (4, false),
8 => (4, true),
9 => (5, false),
10 => (5, true),
_ => (6, false),
};
(oct * 7 + step, sharp)
}
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_score;
if !open {
return;
}
egui::Window::new("Score Editor").open(&mut open).default_size(vec2(900.0, 340.0)).show(ctx, |ui| {
let Some(cid) = crate::midi_editor::target_clip(app) else {
ui.label("Select a MIDI clip or MIDI track.");
return;
};
let s = app.engine.session();
let Some((tid, clip)) = s.find_clip(cid).map(|(t, c)| (t, c.clone())) else { return };
let ClipContent::Midi { sequence } = &clip.content else { return };
let title = s.track(tid).map_or(String::new(), |t| t.name.clone());
ui.label(egui::RichText::new(format!("{title} — {}", clip.name)).font(bold(13.0)));
let len_ticks = (s.tempo.samples_to_ticks(clip.end(), s.sample_rate) - s.tempo.samples_to_ticks(clip.start, s.sample_rate)).max(1);
let px_per_tick = 0.07f32;
let width = (len_ticks as f32 * px_per_tick + 120.0).max(ui.available_width());
egui::ScrollArea::horizontal().show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(width, 230.0), Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 4.0, Color32::from_rgb(246, 243, 236));
let ink = Color32::from_rgb(20, 20, 24);
let left = r.min.x + 70.0;
// Treble staff: E4..F5 (steps 2..10 above C4); bass: G2..A3 (steps -10..-2).
let c4_y = r.min.y + 115.0;
let y_of = |step: i32| c4_y - step as f32 * STEP;
for line in [2, 4, 6, 8, 10, -2, -4, -6, -8, -10] {
let y = y_of(line);
p.line_segment([pos2(r.min.x + 10.0, y), pos2(r.max.x - 10.0, y)], Stroke::new(1.0, ink));
}
p.text(pos2(r.min.x + 22.0, y_of(6)), Align2::CENTER_CENTER, "𝄞", regular(36.0), ink);
// Bass clef, drawn: a curl from the F line plus two dots around it.
let f = pos2(r.min.x + 18.0, y_of(-4));
let curl: Vec<Pos2> = (0..=20)
.map(|i| {
let a = -2.6 + i as f32 * 0.19;
let rad = 7.0 + i as f32 * 0.35;
pos2(f.x + a.cos() * rad * 0.9 + 2.0, f.y + a.sin() * rad + 4.0)
})
.collect();
p.circle_filled(pos2(f.x - 4.0, f.y), 3.0, ink);
p.add(Shape::line(curl, Stroke::new(2.2, ink)));
p.circle_filled(pos2(f.x + 14.0, y_of(-3)), 1.8, ink);
p.circle_filled(pos2(f.x + 14.0, y_of(-5)), 1.8, ink);
p.line_segment([pos2(r.min.x + 10.0, y_of(10)), pos2(r.min.x + 10.0, y_of(-10))], Stroke::new(2.0, ink));
// Bar lines.
let base = s.tempo.samples_to_ticks(clip.start, s.sample_rate);
let mut bar_tick = 0i64;
let mut guard = 0;
while bar_tick <= len_ticks && guard < 2000 {
let x = left + bar_tick as f32 * px_per_tick;
p.line_segment([pos2(x - 8.0, y_of(10)), pos2(x - 8.0, y_of(-10))], Stroke::new(1.0, ink));
let m = s.tempo.meter_at_tick(base + bar_tick);
let bb = s.tempo.bar_beat_at_tick(base + bar_tick);
p.text(pos2(x - 6.0, y_of(12)), Align2::LEFT_BOTTOM, bb.bar.to_string(), regular(9.0), Color32::from_rgb(120, 110, 100));
bar_tick += m.ticks_per_bar().max(1);
guard += 1;
}
// Notes.
for n in &sequence.notes {
let x = left + n.start as f32 * px_per_tick;
let (step, sharp) = diatonic(n.pitch);
let y = y_of(step);
// Ledger lines.
let ledgers: Vec<i32> = if step >= 12 {
(12..=step).step_by(2).collect()
} else if step <= -12 {
(step..=-12).filter(|s| s % 2 == 0).collect()
} else if step == 0 || step == 1 {
vec![0]
} else {
Vec::new()
};
for l in ledgers {
p.line_segment([pos2(x - 7.0, y_of(l)), pos2(x + 7.0, y_of(l))], Stroke::new(1.0, ink));
}
let q = n.length as f32 / TICKS_PER_QUARTER as f32;
let hollow = q >= 1.75;
let head = Rect::from_center_size(pos2(x, y), vec2(10.0, 7.5));
if hollow {
p.add(Shape::ellipse_stroke(head.center(), head.size() * 0.5, Stroke::new(1.6, ink)));
} else {
p.add(Shape::ellipse_filled(head.center(), head.size() * 0.5, ink));
}
if sharp {
p.text(pos2(x - 12.0, y), Align2::CENTER_CENTER, "♯", regular(12.0), ink);
}
if q < 3.5 {
// Stems up below the middle line of each staff, down above it.
let up = if step >= 0 { step < 6 } else { step < -6 };
let (sx, sy0, sy1): (f32, f32, f32) = if up { (x + 4.5, y, y - 26.0) } else { (x - 4.5, y, y + 26.0) };
p.line_segment([pos2(sx, sy0), pos2(sx, sy1)], Stroke::new(1.2, ink));
let flags = if q <= 0.3 {
2
} else if q <= 0.6 {
1
} else {
0
};
for f in 0..flags {
let fy = sy1 + if up { f as f32 * 6.0 } else { -(f as f32) * 6.0 };
let tip: Pos2 = pos2(sx + 8.0, fy + if up { 10.0 } else { -10.0 });
p.line_segment([pos2(sx, fy), tip], Stroke::new(1.6, ink));
}
}
}
});
ui.label(egui::RichText::new("Notation view of the MIDI clip; edit notes in the MIDI Editor or Event List.").small());
});
app.ui.show_score = open;
}
#[cfg(test)]
mod tests {
#[test]
fn diatonic_steps() {
assert_eq!(super::diatonic(60), (0, false));
assert_eq!(super::diatonic(61), (0, true));
assert_eq!(super::diatonic(72), (7, false));
assert_eq!(super::diatonic(48), (-7, false));
assert_eq!(super::diatonic(64), (2, false));
}
}