pm-ui: drum notation view (first pass)

draw_notation() renders a bar as standard drum notation: 5-line staff + time
signature, voices mapped to staff positions and notehead types (oval drums,
cross hi-hat/cymbals), hands stem-up / feet stem-down, beamed eighths/sixteenths
grouped per beat, accents tinted. Developed entirely in the simulator
(uisim --bin notesim → PNG). Firmware build unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 23:59:57 -05:00
parent 04350f9d09
commit 93617e1a91
2 changed files with 171 additions and 0 deletions

View file

@ -244,6 +244,128 @@ where
Ok(())
}
// ---- drum notation ----
enum Head {
Oval,
Cross,
}
/// Map a voice name to (vertical offset from the staff top line, notehead, stem-up?).
/// Offsets are multiples of 6 (half a 12px line-space) so heads land on lines/spaces.
fn map_voice(name: &str) -> (i32, Head, bool) {
if name.starts_with("kick") {
(42, Head::Oval, false) // bass drum: low, stem DOWN (foot)
} 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") {
(0, Head::Cross, true)
} 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") {
(6, Head::Cross, true)
} else {
(24, Head::Oval, true)
}
}
/// Render one bar of the groove as drum notation: 5-line staff, time signature, noteheads with
/// stems (hands up / feet down) and beamed eighths/sixteenths. First pass — refine freely.
pub fn draw_notation<D>(d: &mut D, s: &Screen) -> Result<(), D::Error>
where
D: DrawTarget<Color = Rgb565>,
{
let bb = d.bounding_box();
let w = bb.size.width as i32;
d.clear(BG)?;
// header
Text::new(s.name, Point::new(12, 22), MonoTextStyle::new(&FONT_10X20, TXT)).draw(d)?;
let mut nb = [0u8; 12];
Text::with_alignment(fmt_u32(s.bpm.max(0) as u32, &mut nb), Point::new(w - 12, 18), MonoTextStyle::new(&FONT_9X18_BOLD, CYAN), Alignment::Right).draw(d)?;
let beats = s.lanes.first().map(|l| l.beats.max(1)).unwrap_or(4) as i32;
let staff_top = 80;
let line_gap = 12;
let m = 14;
let clef_w = 34;
let x0 = m + clef_w;
let x1 = w - m;
let bw = (x1 - x0).max(1);
let ink = TXT;
// staff (5 lines)
for i in 0..5 {
let y = staff_top + i * line_gap;
Line::new(Point::new(m, y), Point::new(x1, y)).into_styled(PrimitiveStyle::with_stroke(ink, 1)).draw(d)?;
}
// bar lines (start + end)
Line::new(Point::new(m, staff_top), Point::new(m, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
Line::new(Point::new(x1, staff_top), Point::new(x1, staff_top + 4 * line_gap)).into_styled(PrimitiveStyle::with_stroke(ink, 2)).draw(d)?;
// time signature (beats / 4)
let ts = MonoTextStyle::new(&FONT_10X20, ink);
let mut tb = [0u8; 12];
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;
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() {
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
// notehead
match head {
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)?;
}
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)?;
// 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)?;
}
}
prev_beam = Some((stem_x, beat));
}
}
}
Text::new("drum notation", Point::new(12, staff_top + 4 * line_gap + 40), MonoTextStyle::new(&FONT_6X10, MUTE)).draw(d)?;
Ok(())
}
/// Bring-up diagnostic pattern (kept for hardware bring-up / fallback).
pub fn draw_ui<D>(d: &mut D) -> Result<(), D::Error>
where

View file

@ -0,0 +1,49 @@
//! Render the drum-notation view (pm-ui) for a parsed groove → PNG. `cargo run --bin notesim [prog]`.
use embedded_graphics::{pixelcolor::Rgb565, prelude::*};
use pm_ui::{LaneView, Screen};
const W: u32 = 320;
const H: u32 = 480;
struct Fb {
px: Vec<Rgb565>,
}
impl DrawTarget for Fb {
type Color = Rgb565;
type Error = core::convert::Infallible;
fn draw_iter<I: IntoIterator<Item = Pixel<Rgb565>>>(&mut self, pixels: I) -> Result<(), Self::Error> {
for Pixel(p, c) in pixels {
if p.x >= 0 && p.y >= 0 && (p.x as u32) < W && (p.y as u32) < H {
self.px[(p.y as u32 * W + p.x as u32) as usize] = c;
}
}
Ok(())
}
}
impl OriginDimensions for Fb {
fn size(&self) -> Size {
Size::new(W, H)
}
}
fn main() {
let prog = std::env::args().nth(1).unwrap_or_else(|| "t96;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx".into());
let track = track_format::parse(&prog);
let lanes: Vec<LaneView> = track
.lanes
.iter()
.map(|l| LaneView { name: &l.sound, levels: &l.levels, beats: l.groups.iter().sum::<u32>().min(255) as u8, poly: l.poly, muted: l.mute })
.collect();
let screen = Screen { name: "Rock beat", bpm: track.bpm, playing: false, phase: 0.0, lanes: &lanes };
let mut fb = Fb { px: vec![Rgb565::BLACK; (W * H) as usize] };
pm_ui::draw_notation(&mut fb, &screen).unwrap();
let img = image::RgbImage::from_fn(W, H, |x, y| {
let c = fb.px[(y * W + x) as usize];
image::Rgb([(c.r() << 3) | (c.r() >> 2), (c.g() << 2) | (c.g() >> 4), (c.b() << 3) | (c.b() >> 2)])
});
img.save("notation.png").unwrap();
println!("wrote notation.png for: {prog}");
}