pm-ui: notation refinement — shared stems + ledger lines

Gather notes per time-column across lanes; draw one shared stem per voice (hands
up / feet down) spanning the chord, so snare+hat on a beat share an up-stem. Ledger
lines for notes above/below the staff (hi-hat on its ledger line, crash higher).
Stems always clear the highest/lowest notehead; beams grouped per beat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-01 00:12:21 -05:00
parent 93617e1a91
commit ec29fb7284

View file

@ -245,6 +245,7 @@ where
} }
// ---- drum notation ---- // ---- drum notation ----
#[derive(Clone, Copy)]
enum Head { enum Head {
Oval, Oval,
Cross, Cross,
@ -257,9 +258,11 @@ fn map_voice(name: &str) -> (i32, Head, bool) {
} else if name.starts_with("snare") || name.starts_with("clap") || name.starts_with("rim") { } else if name.starts_with("snare") || name.starts_with("clap") || name.starts_with("rim") {
(18, Head::Oval, true) (18, Head::Oval, true)
} else if name.starts_with("hat") || name.starts_with("openHat") { } else if name.starts_with("hat") || name.starts_with("openHat") {
(-6, Head::Cross, true) // hi-hat: above the staff (-12, Head::Cross, true) // hi-hat: first ledger line above the staff
} else if name.starts_with("ride") || name.starts_with("crash") { } else if name.starts_with("ride") {
(0, Head::Cross, true) (0, Head::Cross, true)
} else if name.starts_with("crash") {
(-24, Head::Cross, true) // crash: high above, more ledgers
} else if name.starts_with("tom") { } else if name.starts_with("tom") {
(12, Head::Oval, true) (12, Head::Oval, true)
} else if name.starts_with("cowbell") || name.starts_with("woodblock") || name.starts_with("claves") || name.starts_with("tambourine") { } else if name.starts_with("cowbell") || name.starts_with("woodblock") || name.starts_with("claves") || name.starts_with("tambourine") {
@ -308,61 +311,112 @@ where
Text::with_alignment(fmt_u32(beats as u32, &mut tb), Point::new(m + clef_w / 2, staff_top + 16), ts, Alignment::Center).draw(d)?; Text::with_alignment(fmt_u32(beats as u32, &mut tb), Point::new(m + clef_w / 2, staff_top + 16), ts, Alignment::Center).draw(d)?;
Text::with_alignment("4", Point::new(m + clef_w / 2, staff_top + 40), ts, Alignment::Center).draw(d)?; Text::with_alignment("4", Point::new(m + clef_w / 2, staff_top + 40), ts, Alignment::Center).draw(d)?;
let stem_len = 30; // Time resolution = finest lane (so off-beats land on columns); lanes whose step count divides
// `res` align to it (polymeter that doesn't divide falls back to its own grid implicitly).
let res = s.lanes.iter().filter(|l| !l.muted).map(|l| l.levels.len() as i32).max().unwrap_or(4).max(1);
let up_end = staff_top - 22; // fixed stem-end levels → horizontal beams
let dn_end = staff_top + 4 * line_gap + 22;
let bot = staff_top + 4 * line_gap;
for lane in s.lanes.iter() { let mut up_prev: Option<(i32, i32)> = None; // (stem_x, beat) for beaming
if lane.muted { let mut dn_prev: Option<(i32, i32)> = None;
continue;
}
let (off, head, up) = map_voice(lane.name);
let hy = staff_top + off;
let steps = lane.levels.len().max(1) as i32;
let sub = (steps / beats).max(1); // steps per beat → note value
let stem_y = if up { hy - stem_len } else { hy + stem_len };
// stem x is on the right for up-stems, left for down-stems for c in 0..res {
let mut prev_beam: Option<(i32, i32)> = None; // (x, beatgroup) of previous beamable note let cx = x0 + c * bw / res + bw / (2 * res);
// gather notes at this column, per voice
let (mut up_lo, mut up_hi, mut up_any, mut up_sub2) = (i32::MIN, i32::MAX, false, false);
let (mut dn_lo, mut dn_hi, mut dn_any, mut dn_sub2) = (i32::MIN, i32::MAX, false, false);
for (i, &lvl) in lane.levels.iter().enumerate() { for lane in s.lanes.iter() {
if lane.muted {
continue;
}
let steps = lane.levels.len() as i32;
if steps == 0 || (c * steps) % res != 0 {
continue; // this lane has no note position at this column
}
let lvl = lane.levels[(c * steps / res) as usize];
if lvl == 0 { if lvl == 0 {
continue; continue;
} }
let i = i as i32; let (off, head, up) = map_voice(lane.name);
let cx = x0 + i * bw / steps + bw / (2 * steps); let hy = staff_top + off;
let stem_x = if up { cx + 5 } else { cx - 5 }; let col = if lvl == 2 { AMBER } else { ink };
let col = if lvl == 2 { AMBER } else { ink }; // accent tint
// notehead // notehead
match head { match head {
Head::Oval => { Head::Oval => embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8))
embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8)) .into_styled(PrimitiveStyle::with_fill(col))
.into_styled(PrimitiveStyle::with_fill(col)) .draw(d)?,
.draw(d)?;
}
Head::Cross => { Head::Cross => {
Line::new(Point::new(cx - 5, hy - 5), Point::new(cx + 5, hy + 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?; Line::new(Point::new(cx - 5, hy - 5), Point::new(cx + 5, hy + 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
Line::new(Point::new(cx - 5, hy + 5), Point::new(cx + 5, hy - 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?; Line::new(Point::new(cx - 5, hy + 5), Point::new(cx + 5, hy - 5)).into_styled(PrimitiveStyle::with_stroke(col, 2)).draw(d)?;
} }
} }
// stem // ledger lines for notes a full line+ above or below the staff
Line::new(Point::new(stem_x, hy), Point::new(stem_x, stem_y)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?; let mut ly = staff_top - 12;
while ly >= hy {
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
ly -= 12;
}
let mut ly = bot + 12;
while ly <= hy {
Line::new(Point::new(cx - 9, ly), Point::new(cx + 9, ly)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
ly += 12;
}
// beam to the previous note if both are subdivided and in the same beat let lsub = steps / beats.max(1);
if sub >= 2 { if up {
let beat = i / sub; up_any = true;
if let Some((px, pbeat)) = prev_beam { up_lo = up_lo.max(hy);
if pbeat == beat { up_hi = up_hi.min(hy);
Rectangle::new(Point::new(px.min(stem_x), stem_y - 1), Size::new((px - stem_x).unsigned_abs() + 2, 4)) up_sub2 |= lsub >= 2;
.into_styled(PrimitiveStyle::with_fill(ink)) } else {
.draw(d)?; dn_any = true;
dn_lo = dn_lo.max(hy);
dn_hi = dn_hi.min(hy);
dn_sub2 |= lsub >= 2;
}
}
let beat = c * beats / res;
// shared up-stem (hands): right side, from lowest head up past the highest head
if up_any {
let sx = cx + 6;
let top = up_end.min(up_hi - 12); // always clear the highest notehead
Line::new(Point::new(sx, up_lo), Point::new(sx, top)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
let up_end = top;
if up_sub2 {
if let Some((px, pb)) = up_prev {
if pb == beat {
Rectangle::new(Point::new(px.min(sx), up_end), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
} }
} }
prev_beam = Some((stem_x, beat)); up_prev = Some((sx, beat));
} else {
up_prev = None;
}
}
// shared down-stem (feet): left side, from highest head down past the lowest head
if dn_any {
let sx = cx - 6;
let bottom = dn_end.max(dn_lo + 12);
Line::new(Point::new(sx, dn_hi), Point::new(sx, bottom)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
let dn_end = bottom;
if dn_sub2 {
if let Some((px, pb)) = dn_prev {
if pb == beat {
Rectangle::new(Point::new(px.min(sx), dn_end - 3), Size::new((px - sx).unsigned_abs(), 4)).into_styled(PrimitiveStyle::with_fill(ink)).draw(d)?;
}
}
dn_prev = Some((sx, beat));
} else {
dn_prev = None;
} }
} }
} }
Text::new("drum notation", Point::new(12, staff_top + 4 * line_gap + 40), MonoTextStyle::new(&FONT_6X10, MUTE)).draw(d)?; Text::new("drum notation", Point::new(12, bot + 40), MonoTextStyle::new(&FONT_6X10, MUTE)).draw(d)?;
Ok(()) Ok(())
} }