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:
parent
93617e1a91
commit
ec29fb7284
1 changed files with 90 additions and 36 deletions
|
|
@ -245,6 +245,7 @@ where
|
|||
}
|
||||
|
||||
// ---- drum notation ----
|
||||
#[derive(Clone, Copy)]
|
||||
enum Head {
|
||||
Oval,
|
||||
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") {
|
||||
(18, Head::Oval, true)
|
||||
} else if name.starts_with("hat") || name.starts_with("openHat") {
|
||||
(-6, Head::Cross, true) // hi-hat: above the staff
|
||||
} else if name.starts_with("ride") || name.starts_with("crash") {
|
||||
(-12, Head::Cross, true) // hi-hat: first ledger line above the staff
|
||||
} else if name.starts_with("ride") {
|
||||
(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") {
|
||||
(12, Head::Oval, true)
|
||||
} 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("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;
|
||||
|
||||
let mut up_prev: Option<(i32, i32)> = None; // (stem_x, beat) for beaming
|
||||
let mut dn_prev: Option<(i32, i32)> = None;
|
||||
|
||||
for c in 0..res {
|
||||
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 lane in s.lanes.iter() {
|
||||
if lane.muted {
|
||||
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
|
||||
let mut prev_beam: Option<(i32, i32)> = None; // (x, beatgroup) of previous beamable note
|
||||
|
||||
for (i, &lvl) in lane.levels.iter().enumerate() {
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
let i = i as i32;
|
||||
let cx = x0 + i * bw / steps + bw / (2 * steps);
|
||||
let stem_x = if up { cx + 5 } else { cx - 5 };
|
||||
let col = if lvl == 2 { AMBER } else { ink }; // accent tint
|
||||
let (off, head, up) = map_voice(lane.name);
|
||||
let hy = staff_top + off;
|
||||
let col = if lvl == 2 { AMBER } else { ink };
|
||||
|
||||
// notehead
|
||||
match head {
|
||||
Head::Oval => {
|
||||
embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8))
|
||||
Head::Oval => embedded_graphics::primitives::Ellipse::new(Point::new(cx - 6, hy - 4), Size::new(12, 8))
|
||||
.into_styled(PrimitiveStyle::with_fill(col))
|
||||
.draw(d)?;
|
||||
}
|
||||
.draw(d)?,
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
// stem
|
||||
Line::new(Point::new(stem_x, hy), Point::new(stem_x, stem_y)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
|
||||
// ledger lines for notes a full line+ above or below the staff
|
||||
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
|
||||
if sub >= 2 {
|
||||
let beat = i / sub;
|
||||
if let Some((px, pbeat)) = prev_beam {
|
||||
if pbeat == beat {
|
||||
Rectangle::new(Point::new(px.min(stem_x), stem_y - 1), Size::new((px - stem_x).unsigned_abs() + 2, 4))
|
||||
.into_styled(PrimitiveStyle::with_fill(ink))
|
||||
.draw(d)?;
|
||||
let lsub = steps / beats.max(1);
|
||||
if up {
|
||||
up_any = true;
|
||||
up_lo = up_lo.max(hy);
|
||||
up_hi = up_hi.min(hy);
|
||||
up_sub2 |= lsub >= 2;
|
||||
} else {
|
||||
dn_any = true;
|
||||
dn_lo = dn_lo.max(hy);
|
||||
dn_hi = dn_hi.min(hy);
|
||||
dn_sub2 |= lsub >= 2;
|
||||
}
|
||||
}
|
||||
prev_beam = Some((stem_x, beat));
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue