From 9bb2501087fa81eda572556ec575ac9975c2a795 Mon Sep 17 00:00:00 2001 From: iohzrd Date: Thu, 7 May 2026 15:35:27 -0700 Subject: [PATCH 1/5] look up default.sf2 in workspace root for cargo run --- neothesia-core/src/utils/resources.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/neothesia-core/src/utils/resources.rs b/neothesia-core/src/utils/resources.rs index 8ce18a48..5ef58e40 100644 --- a/neothesia-core/src/utils/resources.rs +++ b/neothesia-core/src/utils/resources.rs @@ -34,6 +34,16 @@ pub fn default_sf2() -> Option { return Some(path); } + // Development: workspace-root default.sf2 (debug builds only). + #[cfg(debug_assertions)] + if let Some(path) = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|p| p.join("default.sf2")) + && path.exists() + { + return Some(path); + } + let flatpak = PathBuf::from("/app/share/neothesia/default.sf2"); if flatpak.exists() { Some(flatpak) From 638e7385d6c178217433b2d29abecabf39a198df Mon Sep 17 00:00:00 2001 From: iohzrd Date: Thu, 7 May 2026 19:27:30 -0700 Subject: [PATCH 2/5] simple notation --- Cargo.lock | 11 + Cargo.toml | 2 + neothesia-notation/Cargo.toml | 10 + neothesia-notation/src/layout.rs | 355 +++++++++++++ neothesia-notation/src/lib.rs | 222 ++++++++ neothesia-notation/src/render.rs | 498 ++++++++++++++++++ neothesia/Cargo.toml | 1 + neothesia/src/icons.rs | 4 + neothesia/src/scene/playing_scene/mod.rs | 59 +++ .../src/scene/playing_scene/top_bar/mod.rs | 24 +- 10 files changed, 1183 insertions(+), 3 deletions(-) create mode 100644 neothesia-notation/Cargo.toml create mode 100644 neothesia-notation/src/layout.rs create mode 100644 neothesia-notation/src/lib.rs create mode 100644 neothesia-notation/src/render.rs diff --git a/Cargo.lock b/Cargo.lock index 21192f1e..2a1fab0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,6 +1604,7 @@ dependencies = [ "midi-io", "neothesia-core", "neothesia-image", + "neothesia-notation", "nuon", "oxisynth", "piano-layout", @@ -1675,6 +1676,16 @@ dependencies = [ "png", ] +[[package]] +name = "neothesia-notation" +version = "0.0.0" +dependencies = [ + "midi-file", + "neothesia-core", + "profiling", + "wgpu-jumpstart", +] + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 393607f8..6feeb1b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "neothesia-cli", "neothesia-core", "neothesia-image", + "neothesia-notation", "midi-file", "midi-io", "nuon", @@ -28,6 +29,7 @@ midi-io = { path = "./midi-io" } piano-layout = { path = "./piano-layout" } ffmpeg-encoder = { path = "./ffmpeg-encoder" } nuon = { path = "./nuon" } +neothesia-notation = { path = "./neothesia-notation" } anyhow = "1" thiserror = "2.0" diff --git a/neothesia-notation/Cargo.toml b/neothesia-notation/Cargo.toml new file mode 100644 index 00000000..dc8bc0a9 --- /dev/null +++ b/neothesia-notation/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "neothesia-notation" +edition.workspace = true + +[dependencies] +midi-file.workspace = true +neothesia-core.workspace = true +wgpu-jumpstart.workspace = true + +profiling.workspace = true diff --git a/neothesia-notation/src/layout.rs b/neothesia-notation/src/layout.rs new file mode 100644 index 00000000..cdd92b6e --- /dev/null +++ b/neothesia-notation/src/layout.rs @@ -0,0 +1,355 @@ +use std::time::Duration; + +use midi_file::MidiTrack; + +use crate::{ + Chord, Clef, MIDDLE_LINE_POS, MidiPitch, Notehead, Rest, RhythmicValue, Score, Staff, + StaffElement, StaffPos, StaffRange, StemDirection, +}; + +/// A collected note with track metadata for the notation system. +#[derive(Debug, Clone)] +pub struct CollectedNote { + pub start: Duration, + pub duration: Duration, + pub pitch: MidiPitch, + pub color_id: usize, +} + +/// Number of beats per measure assumed by the layout pipeline. +/// Time-signature support would replace this with per-measure data. +const BEATS_PER_MEASURE: u32 = 4; +/// Onset-time tolerance for grouping notes into a chord, in fractions of a beat. +const CHORD_GROUP_BEAT_FRACTION: u32 = 8; +/// Tolerance below which a sub-beat rest gap is treated as zero, in fractions of a beat. +const REST_EPS_BEAT_FRACTION: u32 = 32; + +// ── Note collection ──────────────────────────────────────────────────── + +/// Collect all notes from visible tracks (excluding hidden and drum tracks). +pub fn collect_visible_notes(tracks: &[MidiTrack], hidden_tracks: &[usize]) -> Vec { + let mut notes: Vec = tracks + .iter() + .filter(|track| { + !hidden_tracks.contains(&track.track_id) + && (!track.has_drums || track.has_other_than_drums) + }) + .flat_map(|track| { + track.notes.iter().map(move |note| CollectedNote { + start: note.start, + duration: note.duration, + pitch: note.note, + color_id: track.track_color_id, + }) + }) + .collect(); + + // Sort by start time, then by pitch (higher notes first for chord ordering) + notes.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| b.pitch.cmp(&a.pitch))); + + notes +} + +// ── Duration quantization ────────────────────────────────────────────── + +/// Rhythmically quantized note info. +#[derive(Debug, Clone)] +struct QuantizedNote { + start: Duration, + duration: Duration, + pitch: MidiPitch, + color_id: usize, + rhythmic_value: RhythmicValue, + dotted: bool, +} + +/// `(rhythmic value, beat count)` candidates for quantization, longest first. +const QUANTIZE_CANDIDATES: [(RhythmicValue, f64); 6] = [ + (RhythmicValue::Whole, 4.0), + (RhythmicValue::Half, 2.0), + (RhythmicValue::Quarter, 1.0), + (RhythmicValue::Eighth, 0.5), + (RhythmicValue::Sixteenth, 0.25), + (RhythmicValue::ThirtySecond, 0.125), +]; + +/// Quantize a MIDI duration to the nearest standard rhythmic value. +/// Returns (rhythmic_value, dotted). +fn quantize_duration(duration: Duration, beat_duration: Duration) -> (RhythmicValue, bool) { + let beats = duration.as_secs_f64() / beat_duration.as_secs_f64(); + + // Tolerance pass: snap to a candidate if within window. Dotted candidates + // are tried first because at the same nominal value they have a wider + // absolute window. + for (rv, base) in &QUANTIZE_CANDIDATES { + let dotted = base * 1.5; + if (beats - dotted).abs() < dotted * 0.2 { + return (*rv, true); + } + if (beats - base).abs() < base * 0.25 { + return (*rv, false); + } + } + + // Fallback: find the closest match by absolute distance. + let mut best = (RhythmicValue::Quarter, false); + let mut best_diff = f64::MAX; + for (rv, base) in &QUANTIZE_CANDIDATES { + let diff = (beats - base).abs(); + if diff < best_diff { + best_diff = diff; + best = (*rv, false); + } + let dotted_diff = (beats - base * 1.5).abs(); + if dotted_diff < best_diff { + best_diff = dotted_diff; + best = (*rv, true); + } + } + best +} + +// ── Measure building ─────────────────────────────────────────────────── + +/// Default `(min, max)` staff range when no notes are present: the full +/// 5-line staff in diatonic-position units (0 = bottom line, 8 = top line). +const DEFAULT_RANGE: StaffRange = (0, 8); + +/// Convert collected notes into a flat per-staff timeline, split by clef. +/// +/// `measure_boundaries` is used only to derive `beat_duration` (for chord +/// grouping and rest quantization) and to bound the rendered timeline. +pub fn build_score(notes: &[CollectedNote], measure_boundaries: &[Duration]) -> Score { + let song_start = measure_boundaries.first().copied().unwrap_or(Duration::ZERO); + let song_end = measure_boundaries.last().copied().unwrap_or(Duration::ZERO); + + if measure_boundaries.len() < 2 { + return Score { + treble: Staff { + elements: Vec::new(), + range: DEFAULT_RANGE, + }, + bass: Staff { + elements: Vec::new(), + range: DEFAULT_RANGE, + }, + song_start, + song_end, + }; + } + + let measure_duration = measure_boundaries[1] - measure_boundaries[0]; + let beat_duration = measure_duration / BEATS_PER_MEASURE; + + // Quantize all notes and track each staff's vertical extent in one pass. + // Notes are already sorted by start time (descending pitch on ties). + let mut treble_range = DEFAULT_RANGE; + let mut bass_range = DEFAULT_RANGE; + let quantized: Vec = notes + .iter() + .map(|n| { + let clef = Clef::for_pitch(n.pitch); + let pos = clef.midi_to_staff_pos(n.pitch); + let range = match clef { + Clef::Treble => &mut treble_range, + Clef::Bass => &mut bass_range, + }; + range.0 = range.0.min(pos); + range.1 = range.1.max(pos); + let (rv, dotted) = quantize_duration(n.duration, beat_duration); + QuantizedNote { + start: n.start, + duration: n.duration, + pitch: n.pitch, + color_id: n.color_id, + rhythmic_value: rv, + dotted, + } + }) + .collect(); + + // Group simultaneous notes (within 1/8 beat of the group's first onset) + // into chords across the whole song. + let chord_threshold = beat_duration / CHORD_GROUP_BEAT_FRACTION; + let mut chord_groups: Vec> = Vec::new(); + for note in &quantized { + let extends_last = chord_groups + .last() + .is_some_and(|last| note.start.saturating_sub(last[0].start) <= chord_threshold); + if extends_last { + chord_groups.last_mut().unwrap().push(note); + } else { + chord_groups.push(vec![note]); + } + } + + // For each chord group, emit one chord element per staff that has notes. + let mut treble_chords: Vec = Vec::new(); + let mut bass_chords: Vec = Vec::new(); + for group in &chord_groups { + // Groups are non-empty by construction, so first() is always Some. + let group_start = group[0].start; + let (treble_notes, bass_notes): (Vec<&QuantizedNote>, Vec<&QuantizedNote>) = group + .iter() + .copied() + .partition(|n| Clef::for_pitch(n.pitch) == Clef::Treble); + if !treble_notes.is_empty() { + treble_chords.push(build_chord_element(Clef::Treble, &treble_notes, group_start)); + } + if !bass_notes.is_empty() { + bass_chords.push(build_chord_element(Clef::Bass, &bass_notes, group_start)); + } + } + + let treble_elements = fill_rests( + &treble_chords, + song_start, + song_end, + beat_duration, + measure_duration, + ); + let bass_elements = fill_rests( + &bass_chords, + song_start, + song_end, + beat_duration, + measure_duration, + ); + + Score { + treble: Staff { + elements: treble_elements, + range: treble_range, + }, + bass: Staff { + elements: bass_elements, + range: bass_range, + }, + song_start, + song_end, + } +} + +/// Build a single chord element for one staff from a subset of a chord group. +/// `notes` must be non-empty. +fn build_chord_element( + clef: Clef, + notes: &[&QuantizedNote], + start_time: Duration, +) -> StaffElement { + let heads: Vec = notes + .iter() + .map(|n| Notehead { + staff_pos: clef.midi_to_staff_pos(n.pitch), + color_id: n.color_id, + start_time: n.start, + end_time: n.start + n.duration, + }) + .collect(); + + // Stem direction: the head whose staff position is furthest from the + // middle line wins, with the stem pointing AWAY from that side. Equal + // distances point down (engraving convention). + let (max_pos, min_pos) = heads.iter().map(|h| h.staff_pos).fold( + (StaffPos::MIN, StaffPos::MAX), + |(mx, mn), p| (mx.max(p), mn.min(p)), + ); + let above = (max_pos as i16 - MIDDLE_LINE_POS as i16).max(0); + let below = (MIDDLE_LINE_POS as i16 - min_pos as i16).max(0); + let stem_direction = if above >= below { + StemDirection::Down + } else { + StemDirection::Up + }; + + // Longest rhythmic value among heads on this staff drives the chord. + let (rhythmic_value, dotted) = notes + .iter() + .map(|n| (n.rhythmic_value, n.dotted)) + .max_by(|a, b| a.0.beats_with_dot(a.1).total_cmp(&b.0.beats_with_dot(b.1))) + .unwrap_or((RhythmicValue::Quarter, false)); + + StaffElement::Chord(Chord { + heads, + rhythmic_value, + dotted, + stem_direction, + start_time, + }) +} + +/// Walk a staff's chord list in time order, inserting rests to fill silences. +/// `chords` contains only `StaffElement::Chord`, in time order. Long stretches +/// of silence emit one whole rest per `measure_duration` worth of time so the +/// eye still gets a "lots of rest here" signal. +fn fill_rests( + chords: &[StaffElement], + song_start: Duration, + song_end: Duration, + beat_duration: Duration, + measure_duration: Duration, +) -> Vec { + let mut out: Vec = Vec::with_capacity(chords.len()); + // Tolerance to avoid emitting tiny sub-millisecond rests from rounding. + let eps = beat_duration / REST_EPS_BEAT_FRACTION; + + let mut cursor = song_start; + for el in chords { + let StaffElement::Chord(chord) = el else { + continue; + }; + push_silence_until( + chord.start_time, + &mut cursor, + measure_duration, + beat_duration, + eps, + &mut out, + ); + out.push(el.clone()); + cursor = + chord.start_time + chord.rhythmic_value.duration(chord.dotted, beat_duration); + } + + // Trailing silence until song_end. + push_silence_until( + song_end, + &mut cursor, + measure_duration, + beat_duration, + eps, + &mut out, + ); + + out +} + +/// Emit rests from `*cursor` up to (but not past) `target`. Long gaps emit +/// one whole rest per `measure_duration`; the remainder is quantized. +fn push_silence_until( + target: Duration, + cursor: &mut Duration, + measure_duration: Duration, + beat_duration: Duration, + eps: Duration, + out: &mut Vec, +) { + while target >= *cursor + measure_duration + eps { + out.push(StaffElement::Rest(Rest { + rhythmic_value: RhythmicValue::Whole, + dotted: false, + start_time: *cursor, + })); + *cursor += measure_duration; + } + if target > *cursor + eps { + let gap = target - *cursor; + let (rv, dotted) = quantize_duration(gap, beat_duration); + out.push(StaffElement::Rest(Rest { + rhythmic_value: rv, + dotted, + start_time: *cursor, + })); + *cursor = target; + } +} diff --git a/neothesia-notation/src/lib.rs b/neothesia-notation/src/lib.rs new file mode 100644 index 00000000..57860117 --- /dev/null +++ b/neothesia-notation/src/lib.rs @@ -0,0 +1,222 @@ +//! Sheet music notation layout and rendering for Neothesia. +//! +//! Converts MIDI note data into a structured score representation +//! and renders it as a scrolling grand staff (treble + bass clefs). + +mod layout; +pub mod render; + +use midi_file::MidiTrack; +use std::time::Duration; + +// ── Musical types ────────────────────────────────────────────────────── + +/// A pitch as a MIDI note number (21 = A0, 60 = C4, 108 = C8). +pub type MidiPitch = u8; + +/// Diatonic position on a 5-line staff. Each integer is one diatonic step +/// (line OR space): lines are at even positions, spaces at odd. 0 = bottom +/// line, 8 = top line. Treble: 0 = E4, 4 = B4 (middle line), 8 = F5. +/// Bass: 0 = G2, 4 = D3 (middle line), 8 = A3. Values can go above 8 +/// (ledger lines above) or below 0 (ledger lines below). +pub type StaffPos = i8; + +/// Diatonic position of the middle line (B4 on treble, D3 on bass). +pub const MIDDLE_LINE_POS: StaffPos = 4; + +/// A musical note duration expressed as a fraction of a whole note. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RhythmicValue { + Whole, + Half, + Quarter, + Eighth, + Sixteenth, + ThirtySecond, +} + +impl RhythmicValue { + /// Beats this note occupies (assuming quarter-note beat). + pub const fn beats(self) -> f32 { + match self { + RhythmicValue::Whole => 4.0, + RhythmicValue::Half => 2.0, + RhythmicValue::Quarter => 1.0, + RhythmicValue::Eighth => 0.5, + RhythmicValue::Sixteenth => 0.25, + RhythmicValue::ThirtySecond => 0.125, + } + } + + /// Beats this note occupies, accounting for the augmentation dot (1.5×). + pub fn beats_with_dot(self, dotted: bool) -> f32 { + self.beats() * if dotted { 1.5 } else { 1.0 } + } + + /// Wall-clock duration of this rhythmic value at a given beat duration. + pub fn duration(self, dotted: bool, beat_duration: Duration) -> Duration { + beat_duration.mul_f32(self.beats_with_dot(dotted)) + } +} + +/// A single notehead within a chord. +#[derive(Debug, Clone)] +pub struct Notehead { + pub staff_pos: StaffPos, + /// The color index from the MIDI track, for coloring. + pub color_id: usize, + /// Absolute start time of this note. + pub start_time: Duration, + /// Absolute end time of this note. + pub end_time: Duration, +} + +/// One or more noteheads sharing a stem, rhythmic value, and x position. +/// A single note is a chord with one head. The stem is only drawn when +/// `rhythmic_value` is not [`RhythmicValue::Whole`]. +#[derive(Debug, Clone)] +pub struct Chord { + pub heads: Vec, + pub rhythmic_value: RhythmicValue, + pub dotted: bool, + pub stem_direction: StemDirection, + /// Onset time of the chord within the score. + pub start_time: Duration, +} + +/// Stem direction for a note. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StemDirection { + Up, + Down, +} + +/// A rest element on the staff. +#[derive(Debug, Clone)] +pub struct Rest { + pub rhythmic_value: RhythmicValue, + pub dotted: bool, + /// Onset time of the rest within the score. + pub start_time: Duration, +} + +/// A musical element on a staff: either a chord or a rest. +#[derive(Debug, Clone)] +pub enum StaffElement { + Chord(Chord), + Rest(Rest), +} + +impl StaffElement { + pub fn start_time(&self) -> Duration { + match self { + StaffElement::Chord(c) => c.start_time, + StaffElement::Rest(r) => r.start_time, + } + } +} + +/// Which clef a staff uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Clef { + Treble, + Bass, +} + +impl Clef { + /// Diatonic index (`7*octave + note_index`) of the bottom line of this + /// staff. Treble bottom line is E4 = `5*7 + 2`; bass bottom line is + /// G2 = `3*7 + 4`. + const fn bottom_diatonic(self) -> i16 { + match self { + Clef::Treble => 37, + Clef::Bass => 25, + } + } + + /// Convert a MIDI pitch to a diatonic staff position. + /// 0 = bottom line, 1 = bottom space, 2 = second line, ... + /// Accidentals (sharps/flats) share the position of their natural note. + pub const fn midi_to_staff_pos(self, pitch: MidiPitch) -> StaffPos { + (midi_to_diatonic(pitch) - self.bottom_diatonic()) as StaffPos + } + + /// Split point between treble and bass clefs: pitches >= this go to treble. + /// Middle C (C4) goes to treble. + pub const SPLIT_POINT: MidiPitch = 60; + + /// Choose a clef for a pitch, splitting at [`Clef::SPLIT_POINT`]. + pub const fn for_pitch(pitch: MidiPitch) -> Self { + if pitch >= Self::SPLIT_POINT { + Self::Treble + } else { + Self::Bass + } + } +} + +/// Convert a MIDI pitch (0-127) to a diatonic index. +/// Diatonic index: 7 * octave + note_name_index +/// where note_name_index: C=0, D=1, E=2, F=3, G=4, A=5, B=6. +/// +/// Chromatic notes (sharps/flats) share the diatonic index of their natural. +const fn midi_to_diatonic(pitch: MidiPitch) -> i16 { + // Natural note index for each MIDI semitone 0-11 + const DIATONIC_INDEX: [i16; 12] = [ + 0, // 0: C + 0, // 1: C# -> same position as C + 1, // 2: D + 1, // 3: D# -> same position as D + 2, // 4: E + 3, // 5: F + 3, // 6: F# -> same position as F + 4, // 7: G + 4, // 8: G# -> same position as G + 5, // 9: A + 5, // 10: A# -> same position as A + 6, // 11: B + ]; + + let semitone = (pitch % 12) as usize; + let octave = (pitch / 12) as i16; + octave * 7 + DIATONIC_INDEX[semitone] +} + +/// Inclusive `(min, max)` staff position range for a single staff. +pub type StaffRange = (StaffPos, StaffPos); + +/// A single staff line in the score: a flat, time-sorted list of elements +/// plus its vertical extent. +#[derive(Debug, Clone)] +pub struct Staff { + pub elements: Vec, + /// `(min, max)` staff position observed across all notes on this staff. + /// Used by the renderer to size top/bottom padding for ledger-line space. + pub range: StaffRange, +} + +/// The complete score generated from MIDI data. +#[derive(Debug, Clone)] +pub struct Score { + pub treble: Staff, + pub bass: Staff, + /// Time at which the rendered score begins (typically `Duration::ZERO`). + pub song_start: Duration, + /// Time at which the rendered score ends. + pub song_end: Duration, +} + +// ── Conversion from MIDI ─────────────────────────────────────────────── + +/// Build a Score from MIDI file data. +/// +/// Only notes from visible, non-drum tracks are included. +/// Notes are split between treble and bass staves at middle C (60). +pub fn score_from_midi( + tracks: &[MidiTrack], + hidden_tracks: &[usize], + measures: &[Duration], +) -> Score { + let notes = layout::collect_visible_notes(tracks, hidden_tracks); + layout::build_score(¬es, measures) +} diff --git a/neothesia-notation/src/render.rs b/neothesia-notation/src/render.rs new file mode 100644 index 00000000..c9b3dbef --- /dev/null +++ b/neothesia-notation/src/render.rs @@ -0,0 +1,498 @@ +use std::time::Duration; + +use neothesia_core::render::{QuadInstance, QuadRenderer}; +use wgpu_jumpstart::Color; + +use neothesia_core::config::ColorSchemaV1; + +use crate::{ + Chord, MIDDLE_LINE_POS, Notehead, Rest, RhythmicValue, Score, Staff, StaffElement, StaffPos, + StaffRange, StemDirection, +}; + +/// Diatonic position of the top staff line (5 lines × 2 diatonic steps - 2). +const TOP_LINE_POS: StaffPos = 8; + +// ── Base layout constants ────────────────────────────────────────────── + +/// Stem height as a multiple of the staff line spacing. +const STEM_HEIGHT_LINES: f32 = 3.5; + +const BASE_LINE_SPACING: f32 = 12.0; +const BASE_NOTE_HEAD_WIDTH: f32 = 14.0; +const BASE_NOTE_HEAD_HEIGHT: f32 = 10.5; +const BASE_STEM_HEIGHT: f32 = BASE_LINE_SPACING * STEM_HEIGHT_LINES; +const BASE_STEM_THICKNESS: f32 = 2.0; + +/// How far past the visible viewport edges the staff lines are drawn, +/// so the lines are still visible during scrolling. +const STAFF_LINE_OVERDRAW: f32 = 200.0; + +/// Approximate seconds of music visible across the viewport. +/// Smaller = more zoom (less time per screen). +const TARGET_SECONDS_VISIBLE: f32 = 14.0; +const MIN_PIXELS_PER_SEC: f32 = 50.0; +const MAX_PIXELS_PER_SEC: f32 = 600.0; + +// ── Colors ───────────────────────────────────────────────────────────── + +const PAPER_WHITE: Color = Color { + r: 0.96, + g: 0.95, + b: 0.93, + a: 1.0, +}; +const INK_BLACK: Color = Color { + r: 0.08, + g: 0.08, + b: 0.08, + a: 1.0, +}; +const STAFF_GRAY: Color = Color { + r: 0.75, + g: 0.75, + b: 0.75, + a: 1.0, +}; +const PLAYHEAD_COLOR: Color = Color { + r: 0.85, + g: 0.25, + b: 0.25, + a: 1.0, +}; +const PLAYHEAD_WIDTH: f32 = 2.0; + +/// Pull the per-track note color from the schema, defaulting to ink black +/// when no schema is configured. +fn track_color(color_id: usize, schema: &[ColorSchemaV1]) -> Color { + if schema.is_empty() { + return INK_BLACK; + } + let entry = &schema[color_id % schema.len()]; + Color { + r: entry.base.0 as f32 / 255.0, + g: entry.base.1 as f32 / 255.0, + b: entry.base.2 as f32 / 255.0, + a: 1.0, + } +} + +// ── Public render args ──────────────────────────────────────────────── + +/// Where on screen the notation panel lives, plus its scroll offset. +pub struct RenderTarget { + /// Window width in logical pixels. + pub viewport_width: f32, + /// Notation panel height in logical pixels. + pub notation_height: f32, + /// Notation-coord x at the left edge of the viewport. Negative scrolls + /// the music to the right (used near song start to keep the playhead + /// centered). + pub scroll_x: f32, + /// Y position where the notation panel starts (e.g. below the top bar). + pub top_offset: f32, +} + +// ── Per-render scale + context ───────────────────────────────────────── + +/// Pixel sizes for one frame, derived from `notation_h` and the staff range. +#[derive(Clone, Copy)] +struct Scale { + /// Vertical spacing between staff lines. + ls: f32, + /// Notehead width. + nhw: f32, + /// Notehead height. + nhh: f32, + /// Stem height past the end notehead. + sh: f32, + /// Stem thickness. + st: f32, +} + +/// Per-frame state shared by every drawing helper. +struct RenderCtx<'a> { + scale: Scale, + current_time: Duration, + color_schema: &'a [ColorSchemaV1], + /// Notation-coord x at the left edge of the viewport. + scroll_x: f32, + /// Notation-coord x bounds for visibility culling (with a small pad). + vis_lo: f32, + vis_hi: f32, +} + +// ── Renderer ─────────────────────────────────────────────────────────── + +pub struct NotationRenderer { + score: Score, + pixels_per_sec: f32, +} + +/// Pixels-per-second for the given viewport width, clamped to sane bounds. +fn pixels_per_sec_for(viewport_w: f32) -> f32 { + (viewport_w / TARGET_SECONDS_VISIBLE).clamp(MIN_PIXELS_PER_SEC, MAX_PIXELS_PER_SEC) +} + +impl NotationRenderer { + pub fn new(score: Score, viewport_width: f32) -> Self { + Self { + score, + pixels_per_sec: pixels_per_sec_for(viewport_width), + } + } + + /// Update horizontal scale to match the current viewport. Cheap; safe to + /// call every frame. + pub fn set_viewport_width(&mut self, viewport_w: f32) { + self.pixels_per_sec = pixels_per_sec_for(viewport_w); + } + + /// Notation-coordinate x position of an absolute time `t`. + fn x_at(&self, t: Duration) -> f32 { + t.saturating_sub(self.score.song_start).as_secs_f32() * self.pixels_per_sec + } + + pub fn height_for_viewport(viewport_h: f32) -> f32 { + (viewport_h * 0.25).clamp(180.0, 400.0) + } + + /// X position (in notation coords) of the playhead at `time`. Linear in + /// real time, so the playhead glides at constant pixels-per-second across + /// the entire score. + pub fn playhead_x(&self, time: Duration) -> f32 { + self.x_at(time.clamp(self.score.song_start, self.score.song_end)) + } + + #[profiling::function] + pub fn render( + &self, + quads: &mut QuadRenderer, + target: &RenderTarget, + current_time: Duration, + color_schema: &[ColorSchemaV1], + ) { + let layout = + compute_vertical_layout(&self.score, target.notation_height, target.top_offset); + let scale = layout.scale(); + let pad = scale.nhw; + let ctx = RenderCtx { + scale, + current_time, + color_schema, + scroll_x: target.scroll_x, + vis_lo: target.scroll_x - pad, + vis_hi: target.scroll_x + target.viewport_width + pad, + }; + + // Background. + push_rect( + quads, + 0.0, + target.top_offset, + target.viewport_width, + target.notation_height, + PAPER_WHITE, + ); + + // Staff lines. + draw_staff_lines( + quads, + layout.treble_y, + scale.ls, + target.scroll_x, + target.viewport_width, + ); + draw_staff_lines( + quads, + layout.bass_y, + scale.ls, + target.scroll_x, + target.viewport_width, + ); + + // Elements per staff. + self.draw_staff(quads, &self.score.treble, layout.treble_y, &ctx); + self.draw_staff(quads, &self.score.bass, layout.bass_y, &ctx); + + // Playhead — the scroll keeps `current_time` mapped to viewport center, + // so the "now" indicator is just a static line at viewport_w / 2. + push_rect( + quads, + target.viewport_width / 2.0 - PLAYHEAD_WIDTH / 2.0, + target.top_offset, + PLAYHEAD_WIDTH, + target.notation_height, + PLAYHEAD_COLOR, + ); + } + + fn draw_staff( + &self, + quads: &mut QuadRenderer, + staff: &Staff, + sy: f32, + ctx: &RenderCtx, + ) { + for el in &staff.elements { + let nx = self.x_at(el.start_time()); + if nx < ctx.vis_lo || nx > ctx.vis_hi { + continue; + } + let x = nx - ctx.scroll_x; + match el { + StaffElement::Chord(chord) => draw_chord(quads, chord, x, sy, ctx), + StaffElement::Rest(rest) => draw_rest(quads, rest, x, sy, ctx.scale), + } + } + } + +} + +// ── Free drawing helpers ─────────────────────────────────────────────── + +/// Convert a diatonic staff position to a y offset within a staff. Two +/// diatonic positions (one line + one space) make one line spacing, so the +/// scale factor is `ls / 2`. Higher staff_pos = smaller y on screen. +fn staff_pos_to_y(pos: StaffPos, ls: f32) -> f32 { + ls * 4.0 - pos as f32 * ls / 2.0 +} + +fn draw_chord(quads: &mut QuadRenderer, chord: &Chord, x: f32, sy: f32, ctx: &RenderCtx) { + let filled = chord.rhythmic_value != RhythmicValue::Whole + && chord.rhythmic_value != RhythmicValue::Half; + + // Active color is shared across heads + stem; precompute once. + let active_color = chord + .heads + .iter() + .find(|h| ctx.current_time >= h.start_time && ctx.current_time < h.end_time) + .map(|h| track_color(h.color_id, ctx.color_schema)); + + for head in &chord.heads { + let active = ctx.current_time >= head.start_time && ctx.current_time < head.end_time; + let head_color = if active { + track_color(head.color_id, ctx.color_schema) + } else { + INK_BLACK + }; + draw_notehead(quads, head, x, sy, ctx.scale, head_color, filled); + draw_ledger_lines(quads, head.staff_pos, x, sy, ctx.scale, head_color); + if chord.dotted { + // For noteheads on a line (even staff_pos), the dot is raised + // into the space above; for space-noteheads the dot sits in + // the same space. + let on_line = head.staff_pos.rem_euclid(2) == 0; + let y = sy + staff_pos_to_y(head.staff_pos, ctx.scale.ls) + - if on_line { ctx.scale.ls / 2.0 } else { 0.0 }; + draw_dot(quads, x + ctx.scale.nhw * 0.7, y, ctx.scale, head_color); + } + } + + if chord.rhythmic_value != RhythmicValue::Whole { + let stem_color = active_color.unwrap_or(INK_BLACK); + draw_stem(quads, chord, x, sy, ctx.scale, stem_color); + } +} + +fn draw_stem(quads: &mut QuadRenderer, chord: &Chord, x: f32, sy: f32, scale: Scale, color: Color) { + // Higher staff_pos = smaller y on screen, so `max_pos` gives the visually + // top notehead and `min_pos` the visually bottom one. + let (max_pos, min_pos) = chord + .heads + .iter() + .fold((StaffPos::MIN, StaffPos::MAX), |(mx, mn), h| { + (mx.max(h.staff_pos), mn.min(h.staff_pos)) + }); + let y_top = sy + staff_pos_to_y(max_pos, scale.ls); + let y_bot = sy + staff_pos_to_y(min_pos, scale.ls); + let height = scale.sh + (y_bot - y_top); + + let (stem_x, stem_y) = match chord.stem_direction { + StemDirection::Up => (x + scale.nhw / 2.0 - scale.st / 2.0, y_top - scale.sh), + StemDirection::Down => (x - scale.nhw / 2.0, y_top), + }; + push_rect(quads, stem_x, stem_y, scale.st, height, color); +} + +/// Push a flat-colored rectangle (no border radius). +fn push_rect(quads: &mut QuadRenderer, x: f32, y: f32, w: f32, h: f32, color: Color) { + quads.push(QuadInstance { + position: [x, y], + size: [w, h], + color: color.into_linear_rgba(), + border_radius: [0.0; 4], + }); +} + +/// Draw the five horizontal lines of a staff at vertical baseline `top_y`. +fn draw_staff_lines(quads: &mut QuadRenderer, top_y: f32, ls: f32, scroll_x: f32, viewport_w: f32) { + let x = -scroll_x - STAFF_LINE_OVERDRAW; + let w = viewport_w + scroll_x + STAFF_LINE_OVERDRAW * 2.0; + for line in 0..5 { + push_rect(quads, x, top_y + line as f32 * ls, w, 1.0, STAFF_GRAY); + } +} + +/// Draw a single notehead (filled for quarter+, donut-shaped for half/whole). +fn draw_notehead( + quads: &mut QuadRenderer, + head: &Notehead, + x: f32, + sy: f32, + scale: Scale, + color: Color, + filled: bool, +) { + let y = sy + staff_pos_to_y(head.staff_pos, scale.ls); + let r = scale.nhh / 2.0; + quads.push(QuadInstance { + position: [x - scale.nhw / 2.0, y - scale.nhh / 2.0], + size: [scale.nhw, scale.nhh], + color: color.into_linear_rgba(), + border_radius: [r; 4], + }); + if !filled { + let b = scale.st; + let inner_r = (scale.nhh - b * 2.0) / 2.0; + quads.push(QuadInstance { + position: [x - scale.nhw / 2.0 + b, y - scale.nhh / 2.0 + b], + size: [scale.nhw - b * 2.0, scale.nhh - b * 2.0], + color: PAPER_WHITE.into_linear_rgba(), + border_radius: [inner_r; 4], + }); + } +} + +/// Draw ledger lines for a notehead that sits above or below the staff. +fn draw_ledger_lines( + quads: &mut QuadRenderer, + pos: StaffPos, + x: f32, + sy: f32, + scale: Scale, + color: Color, +) { + let lw = scale.nhw + 6.0; + let mut draw_line = |lp: StaffPos| { + let ly = sy + staff_pos_to_y(lp, scale.ls); + push_rect(quads, x - lw / 2.0, ly - scale.st / 2.0, lw, scale.st, color); + }; + if pos < 0 { + // -2, -4, -6, ... down to pos (each ledger line is 2 diatonic steps). + let mut lp: StaffPos = -2; + while lp >= pos { + draw_line(lp); + lp -= 2; + } + } else if pos > TOP_LINE_POS { + let mut lp: StaffPos = TOP_LINE_POS + 2; + while lp <= pos { + draw_line(lp); + lp += 2; + } + } +} + +/// Draw a small filled circle (used for augmentation dots). +fn draw_dot(quads: &mut QuadRenderer, x: f32, y: f32, scale: Scale, color: Color) { + let dot = scale.nhh * 0.45; + quads.push(QuadInstance { + position: [x, y - dot / 2.0], + size: [dot, dot], + color: color.into_linear_rgba(), + border_radius: [dot / 2.0; 4], + }); +} + +/// Draw a rest glyph (and its augmentation dot if any). Whole rests hang +/// from the 4th line (the line above the middle line); half rests sit on +/// the middle line; shorter rests are centered on the middle line. +fn draw_rest(quads: &mut QuadRenderer, rest: &Rest, x: f32, sy: f32, scale: Scale) { + let line_pos: StaffPos = match rest.rhythmic_value { + RhythmicValue::Whole => MIDDLE_LINE_POS + 2, + _ => MIDDLE_LINE_POS, + }; + let line_y = sy + staff_pos_to_y(line_pos, scale.ls); + let h = scale.nhh * 0.7; + let top_y = match rest.rhythmic_value { + RhythmicValue::Whole => line_y, // hang below the 4th line + RhythmicValue::Half => line_y - h, // sit above the middle line + _ => line_y - h / 2.0, // centered on the middle line + }; + quads.push(QuadInstance { + position: [x - scale.nhw * 0.4, top_y], + size: [scale.nhw * 0.8, h], + color: INK_BLACK.into_linear_rgba(), + border_radius: [1.0; 4], + }); + if rest.dotted { + // Dot in the space above the rest's anchoring line. + draw_dot( + quads, + x + scale.nhw * 0.5, + line_y - scale.ls / 4.0, + scale, + INK_BLACK, + ); + } +} + +// ── Vertical layout ──────────────────────────────────────────────────── + +/// Y baselines and pixel sizes for one frame's worth of rendering. +struct VerticalLayout { + treble_y: f32, + bass_y: f32, + /// Uniform scale factor applied to all `BASE_*` constants. + s: f32, +} + +impl VerticalLayout { + fn scale(&self) -> Scale { + Scale { + ls: BASE_LINE_SPACING * self.s, + nhw: BASE_NOTE_HEAD_WIDTH * self.s, + nhh: BASE_NOTE_HEAD_HEIGHT * self.s, + sh: BASE_STEM_HEIGHT * self.s, + st: BASE_STEM_THICKNESS * self.s, + } + } +} + +/// Padding (in `BASE_LINE_SPACING` units) needed above the staff: one line +/// of breathing room plus one line spacing per pair of diatonic positions +/// above the top staff line. +fn pad_above(range: StaffRange) -> f32 { + let lines_above = (range.1 - TOP_LINE_POS).max(0) as f32 / 2.0; + (lines_above + 1.0) * BASE_LINE_SPACING +} + +/// Padding needed below the staff: one line of breathing room plus one line +/// spacing per pair of diatonic positions below the bottom staff line. +fn pad_below(range: StaffRange) -> f32 { + let lines_below = (-range.0).max(0) as f32 / 2.0; + (lines_below + 1.0) * BASE_LINE_SPACING +} + +/// Compute the vertical layout for the grand staff: how to allocate `notation_h` +/// pixels across treble padding, treble staff, gap, bass staff, bass padding, +/// scaled to fit the actual ledger-line range of each staff. +fn compute_vertical_layout(score: &Score, notation_h: f32, top_offset: f32) -> VerticalLayout { + let staff_h = BASE_LINE_SPACING * 4.0; + let gap = BASE_LINE_SPACING * 3.0; + let raw_tt = pad_above(score.treble.range); + let raw_tb = pad_below(score.treble.range); + let raw_bt = pad_above(score.bass.range); + let raw_bb = pad_below(score.bass.range); + let raw_total = (raw_tt + staff_h + raw_tb + gap + raw_bt + staff_h + raw_bb).max(1.0); + + let s = notation_h / raw_total; + let treble_y = top_offset + raw_tt * s; + let bass_y = top_offset + (raw_tt + staff_h + raw_tb + gap + raw_bt) * s; + VerticalLayout { + treble_y, + bass_y, + s, + } +} diff --git a/neothesia/Cargo.toml b/neothesia/Cargo.toml index 6b089da1..5cd8dc08 100644 --- a/neothesia/Cargo.toml +++ b/neothesia/Cargo.toml @@ -20,6 +20,7 @@ env_logger.workspace = true wgpu.workspace = true wgpu-jumpstart.workspace = true neothesia-core.workspace = true +neothesia-notation.workspace = true neothesia-image.workspace = true midi-file.workspace = true midi-io.workspace = true diff --git a/neothesia/src/icons.rs b/neothesia/src/icons.rs index 5c449771..4834e6c8 100644 --- a/neothesia/src/icons.rs +++ b/neothesia/src/icons.rs @@ -47,3 +47,7 @@ pub fn note_list_icon() -> &'static str { pub fn caret_down() -> &'static str { "\u{f229}" } + +pub fn music_note_icon() -> &'static str { + "\u{f6be}" +} diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index 5aac36f7..cf6ae037 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -2,6 +2,7 @@ use midi_file::midly::MidiMessage; use neothesia_core::render::{ GlowRenderer, GuidelineRenderer, NoteLabels, QuadRenderer, TextRenderer, }; +use neothesia_notation::render::{NotationRenderer, RenderTarget}; use std::time::Duration; use winit::{ event::WindowEvent, @@ -51,6 +52,13 @@ pub struct PlayingScene { mouse_to_midi_state: MouseToMidiEventState, top_bar: TopBar, + + /// Optional sheet music notation display. + notation: Option, + /// Whether to show the notation area. + show_notation: bool, + /// Scale multiplier for notation area (1.0 = default). + notation_scale: f32, } impl PlayingScene { @@ -92,6 +100,15 @@ impl PlayingScene { ctx.text_renderer_factory.new_renderer(), )); + // Build sheet music notation (before song is moved into MidiPlayer) + let notation_data = neothesia_notation::score_from_midi( + &song.file.tracks, + &hidden_tracks, + &song.file.measures, + ); + let notation = + NotationRenderer::new(notation_data, ctx.window_state.logical_size.width); + let player = MidiPlayer::new( ctx.output_manager.connection().clone(), song, @@ -128,6 +145,10 @@ impl PlayingScene { mouse_to_midi_state: MouseToMidiEventState::default(), top_bar: TopBar::new(), + + notation: Some(notation), + show_notation: true, + notation_scale: 1.0, } } @@ -221,6 +242,32 @@ impl Scene for PlayingScene { self.update_glow(delta); + // Update notation scroll position. Use the same time the waterfall + // uses (lead-in stripped, animation offset applied) so the two views + // stay in sync. + if self.show_notation + && let Some(notation) = self.notation.as_mut() + { + let viewport_w = ctx.window_state.logical_size.width; + notation.set_viewport_width(viewport_w); + let notation_time = Duration::from_secs_f32(time.max(0.0)); + let scroll_x = notation.playhead_x(notation_time) - viewport_w / 2.0; + + let nh = NotationRenderer::height_for_viewport(ctx.window_state.logical_size.height) + * self.notation_scale; + notation.render( + &mut self.quad_renderer_fg, + &RenderTarget { + viewport_width: viewport_w, + notation_height: nh, + scroll_x, + top_offset: top_bar::HEIGHT, + }, + notation_time, + ctx.config.color_schema(), + ); + } + TopBar::update(self, ctx); super::render_nuon(&mut self.nuon, &mut self.nuon_renderer, ctx); @@ -285,6 +332,18 @@ impl Scene for PlayingScene { self.player.pause_resume(); } + if event.key_released(Key::Character("n")) { + self.show_notation = !self.show_notation; + } + + // Notation size: [ to shrink, ] to grow + if event.key_released(Key::Character("[")) { + self.notation_scale = (self.notation_scale - 0.1).max(0.4); + } + if event.key_released(Key::Character("]")) { + self.notation_scale = (self.notation_scale + 0.1).min(2.5); + } + handle_settings_input(ctx, &mut self.toast_manager, &mut self.waterfall, event); super::handle_pc_keyboard_to_midi_event(ctx, event); super::handle_mouse_to_midi_event( diff --git a/neothesia/src/scene/playing_scene/top_bar/mod.rs b/neothesia/src/scene/playing_scene/top_bar/mod.rs index 7ac7a59d..b5d3a3d1 100644 --- a/neothesia/src/scene/playing_scene/top_bar/mod.rs +++ b/neothesia/src/scene/playing_scene/top_bar/mod.rs @@ -7,6 +7,9 @@ use super::{ animation::{Animated, Easing}, }; +/// Logical height of the top bar in window-coordinate pixels. +pub const HEIGHT: f32 = 75.0; + pub struct TopBar { pub topbar_expand_animation: Animated, is_expanded: bool, @@ -59,8 +62,7 @@ impl TopBar { let window_state = &ctx.window_state; - let h = 75.0; - let is_hovered = window_state.cursor_logical_position.y < h * 1.7; + let is_hovered = window_state.cursor_logical_position.y < HEIGHT * 1.7; top_bar.is_expanded = is_hovered; top_bar.is_expanded |= top_bar.settings_active; @@ -81,7 +83,7 @@ impl TopBar { nuon::translate() .y(this.top_bar.topbar_expand_animation.animate_bool( - -75.0 + 5.0, + -HEIGHT + 5.0, 0.0, ctx.frame_timestamp, )) @@ -189,6 +191,22 @@ impl TopBar { nuon::translate().x(-30.0).add_to_current(ui); + // Notation toggle (left of settings gear) + if Self::button() + .icon(icons::music_note_icon()) + .color(if this.show_notation { + [97, 97, 97] + } else { + [67, 67, 67] + }) + .hover_color([107, 107, 107]) + .build(ui) + { + this.show_notation = !this.show_notation; + } + + nuon::translate().x(-30.0).add_to_current(ui); + if Self::button().icon(icons::repeat_icon()).build(ui) { this.top_bar.looper_active = !this.top_bar.looper_active; From a73c6ff51ca69106f6abb39977c3157461fa23b9 Mon Sep 17 00:00:00 2001 From: iohzrd Date: Fri, 8 May 2026 01:43:55 -0700 Subject: [PATCH 3/5] notation: horizontal zoom with -/= --- neothesia-notation/src/render.rs | 23 ++++++++++++++++++----- neothesia/src/scene/playing_scene/mod.rs | 12 ++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/neothesia-notation/src/render.rs b/neothesia-notation/src/render.rs index c9b3dbef..095cfad2 100644 --- a/neothesia-notation/src/render.rs +++ b/neothesia-notation/src/render.rs @@ -127,25 +127,38 @@ struct RenderCtx<'a> { pub struct NotationRenderer { score: Score, pixels_per_sec: f32, + viewport_w: f32, + zoom: f32, } -/// Pixels-per-second for the given viewport width, clamped to sane bounds. -fn pixels_per_sec_for(viewport_w: f32) -> f32 { - (viewport_w / TARGET_SECONDS_VISIBLE).clamp(MIN_PIXELS_PER_SEC, MAX_PIXELS_PER_SEC) +/// Pixels-per-second for the given viewport width and user zoom multiplier, +/// clamped to sane bounds. +fn pixels_per_sec_for(viewport_w: f32, zoom: f32) -> f32 { + (viewport_w / TARGET_SECONDS_VISIBLE * zoom).clamp(MIN_PIXELS_PER_SEC, MAX_PIXELS_PER_SEC) } impl NotationRenderer { pub fn new(score: Score, viewport_width: f32) -> Self { Self { score, - pixels_per_sec: pixels_per_sec_for(viewport_width), + pixels_per_sec: pixels_per_sec_for(viewport_width, 1.0), + viewport_w: viewport_width, + zoom: 1.0, } } /// Update horizontal scale to match the current viewport. Cheap; safe to /// call every frame. pub fn set_viewport_width(&mut self, viewport_w: f32) { - self.pixels_per_sec = pixels_per_sec_for(viewport_w); + self.viewport_w = viewport_w; + self.pixels_per_sec = pixels_per_sec_for(viewport_w, self.zoom); + } + + /// User zoom multiplier on the horizontal density. 1.0 is the default + /// (≈ [`TARGET_SECONDS_VISIBLE`] seconds across the viewport). + pub fn set_zoom(&mut self, zoom: f32) { + self.zoom = zoom; + self.pixels_per_sec = pixels_per_sec_for(self.viewport_w, zoom); } /// Notation-coordinate x position of an absolute time `t`. diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index cf6ae037..a9719c2c 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -59,6 +59,8 @@ pub struct PlayingScene { show_notation: bool, /// Scale multiplier for notation area (1.0 = default). notation_scale: f32, + /// Horizontal zoom multiplier on notation pixels-per-second (1.0 = default). + notation_zoom: f32, } impl PlayingScene { @@ -149,6 +151,7 @@ impl PlayingScene { notation: Some(notation), show_notation: true, notation_scale: 1.0, + notation_zoom: 1.0, } } @@ -250,6 +253,7 @@ impl Scene for PlayingScene { { let viewport_w = ctx.window_state.logical_size.width; notation.set_viewport_width(viewport_w); + notation.set_zoom(self.notation_zoom); let notation_time = Duration::from_secs_f32(time.max(0.0)); let scroll_x = notation.playhead_x(notation_time) - viewport_w / 2.0; @@ -344,6 +348,14 @@ impl Scene for PlayingScene { self.notation_scale = (self.notation_scale + 0.1).min(2.5); } + // Notation horizontal zoom: - to zoom out, = to zoom in + if event.key_released(Key::Character("-")) { + self.notation_zoom = (self.notation_zoom - 0.1).max(0.4); + } + if event.key_released(Key::Character("=")) { + self.notation_zoom = (self.notation_zoom + 0.1).min(2.5); + } + handle_settings_input(ctx, &mut self.toast_manager, &mut self.waterfall, event); super::handle_pc_keyboard_to_midi_event(ctx, event); super::handle_mouse_to_midi_event( From bb39bdc979074d5e720d36d69aaa4897fe557af7 Mon Sep 17 00:00:00 2001 From: iohzrd Date: Fri, 8 May 2026 10:50:41 -0700 Subject: [PATCH 4/5] notation: rebind zoom to [ and ], panel-height to , . The previous -/= horizontal-zoom binding conflicted with the existing animation_offset shortcut --- neothesia/src/scene/playing_scene/mod.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index a9719c2c..b496992d 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -108,8 +108,7 @@ impl PlayingScene { &hidden_tracks, &song.file.measures, ); - let notation = - NotationRenderer::new(notation_data, ctx.window_state.logical_size.width); + let notation = NotationRenderer::new(notation_data, ctx.window_state.logical_size.width); let player = MidiPlayer::new( ctx.output_manager.connection().clone(), @@ -340,20 +339,20 @@ impl Scene for PlayingScene { self.show_notation = !self.show_notation; } - // Notation size: [ to shrink, ] to grow + // Notation horizontal zoom: "[" to zoom out, "]" to zoom in if event.key_released(Key::Character("[")) { - self.notation_scale = (self.notation_scale - 0.1).max(0.4); + self.notation_zoom = (self.notation_zoom - 0.1).max(0.4); } if event.key_released(Key::Character("]")) { - self.notation_scale = (self.notation_scale + 0.1).min(2.5); + self.notation_zoom = (self.notation_zoom + 0.1).min(2.5); } - // Notation horizontal zoom: - to zoom out, = to zoom in - if event.key_released(Key::Character("-")) { - self.notation_zoom = (self.notation_zoom - 0.1).max(0.4); + // Notation panel height: "," to shrink, "." to grow + if event.key_released(Key::Character(",")) { + self.notation_scale = (self.notation_scale - 0.1).max(0.4); } - if event.key_released(Key::Character("=")) { - self.notation_zoom = (self.notation_zoom + 0.1).min(2.5); + if event.key_released(Key::Character(".")) { + self.notation_scale = (self.notation_scale + 0.1).min(2.5); } handle_settings_input(ctx, &mut self.toast_manager, &mut self.waterfall, event); From 63de22937c14ed82a88e0bf44839abc2621d2968 Mon Sep 17 00:00:00 2001 From: iohzrd Date: Fri, 8 May 2026 13:43:38 -0700 Subject: [PATCH 5/5] notation: hide by default --- neothesia/src/scene/playing_scene/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index b496992d..fd41c8ef 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -148,7 +148,7 @@ impl PlayingScene { top_bar: TopBar::new(), notation: Some(notation), - show_notation: true, + show_notation: false, notation_scale: 1.0, notation_zoom: 1.0, }