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 ----
|
// ---- 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue