From 9da524fd66727fc6e42d708d5ded682ae519c9f4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:54:51 -0400 Subject: [PATCH] Score Editor: MIDI as notation on a grand staff Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/src/score_editor.rs | 159 +++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 crates/ui-egui/src/score_editor.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b966fac2..a00632db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ee467553..18467f36 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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), diff --git a/crates/ui-egui/src/score_editor.rs b/crates/ui-egui/src/score_editor.rs new file mode 100644 index 00000000..470d6ef5 --- /dev/null +++ b/crates/ui-egui/src/score_editor.rs @@ -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 = (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 = 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)); + } +}