metronome/src/notation.js
Me Here 5dcef691c1 Add untracked notation deliverables (build/compile depend on these)
- src/notation.js — web notation engine inlined into pm_e-2.html (@BUILD:include)
- rust/pm-ui/src/notation/ — the notation module pm-ui/lib.rs imports
- rust/glyphgen/ + rust/assets/bravura/ (Bravura.otf + OFL.txt) — host atlas generator + font src
- rust/Cargo.toml (workspace) + rust/.gitignore
- assets/bravura.woff2.b64 (web font subset, @BUILD:bravura@) + info-pm_e-2.html

Without these a clean checkout couldn't build pm_e-2.html or compile pm-ui. (Left hardware/eda
make_svg* + kicad/_svgtest.json untracked — unrelated scratch.)
2026-06-02 13:46:45 -05:00

403 lines
21 KiB
JavaScript

/* =========================================================================
PM_E-2 NOTATION ENGINE — inlined into pm_e-2.html by build.sh.
Engraves a groove as a 5-line drum staff onto a 2D <canvas>, using the
Bravura SMuFL music font (subset inlined via @BUILD:bravura@). The draw API
is deliberately IMMEDIATE-MODE and mirrors embedded-graphics (drawGlyph /
line / rect) so the layout math ports near-mechanically to the device
(rust/pm-ui). Pure view over a normalized model — no engine.js internals.
model = {
name, bpm, playing, phase, // phase 0..1 across the master bar (playhead)
lanes: [ { sound, groups:[Int], sub, swing, poly, muted,
levels:[0..3], orns:[0..3] } ] // levels: rest/normal/accent/ghost
}
========================================================================= */
const NOTATION = (() => {
// SMuFL codepoints (resolved from glyphnames.json by tools/bravura/subset.py — keep in sync).
const GLYPH = {
clef: 0xe069,
black: 0xe0a4, x: 0xe0a9, circleX: 0xe0b3, half: 0xe0a3, whole: 0xe0a2,
parenL: 0xe0f5, parenR: 0xe0f6,
flag8U: 0xe240, flag8D: 0xe241, flag16U: 0xe242, flag16D: 0xe243,
restW: 0xe4e3, restH: 0xe4e4, restQ: 0xe4e5, rest8: 0xe4e6, rest16: 0xe4e7,
accentA: 0xe4a0, accentB: 0xe4a1, dot: 0xe1e7,
sig: [0xe080, 0xe081, 0xe082, 0xe083, 0xe084, 0xe085, 0xe086, 0xe087, 0xe088, 0xe089],
sigPlus: 0xe08c, sigCommon: 0xe08a, sigCut: 0xe08b,
graceAcc: 0xe560, graceSlash: 0xe564,
trem1: 0xe220, trem2: 0xe221, trem3: 0xe222, buzz: 0xe22a,
};
const chr = (cp) => String.fromCodePoint(cp);
// Voice -> staff position. `p` = half-staff-spaces below the TOP line (top line p=0, each line/space
// step = 1; bottom line p=8). `head` = notehead glyph; `up` = stem direction (hands up / feet down).
// PAS-style drum key; refined visually in the browser.
function voice(name) {
const s = name || "";
const F = (p, head, up) => ({ p, head, up });
if (s.startsWith("kick")) return F(7, "black", false); // bass drum (feet, stem down)
if (s.startsWith("snare") || s.startsWith("clap") || s.startsWith("rim")) return F(3, "black", true);
if (s.startsWith("openHat") || s.startsWith("hatOpen") || s.startsWith("hat")) return F(-1, "x", true);
if (s.startsWith("ride")) return F(0, "x", true);
if (s.startsWith("crash")) return F(-3, "x", true);
if (s.startsWith("tomHigh")) return F(1, "black", true);
if (s.startsWith("tomMid") || s === "tom808") return F(2, "black", true);
if (s.startsWith("tomLow") || s.startsWith("tom")) return F(5, "black", true);
if (s.startsWith("cowbell")) return F(-1, "circleX", true);
if (s.startsWith("claves") || s.startsWith("woodblock") || s.startsWith("jamblock")) return F(1, "x", true);
if (s.startsWith("tambourine")) return F(-2, "x", true);
return F(3, "black", true);
}
function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a || 1; }
function lcm(a, b) { return a / gcd(a, b) * b; }
// --- palette ---
// The notation panel is engraved like paper: dark ink on a WHITE background, theme-independent
// (matches print sheet music and reads in both page themes).
function palette() {
return {
ink: "#161a1f", // staff lines, noteheads, stems, beams
faint: "#aeb6c0", // box outlines / gridlines / poly-row baseline
accent: "#c0392b", // accents (notehead tint + > mark) — reads on white
ghost: "#7c8794", // ghost notes
play: "#2b7fff", // playhead
bg: "#ffffff",
};
}
function draw(canvas, model, opts) {
const view = (model && model.view) || "staff";
if (view === "tubs") return drawTUBS(canvas, model);
if (view === "konnakol") return drawKonnakol(canvas, model);
opts = opts || {};
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth, H = canvas.clientHeight;
if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
canvas.width = W * dpr; canvas.height = H * dpr;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pal = palette();
ctx.clearRect(0, 0, W, H);
const S = opts.staffSpace || 11; // staff space in px
const em = 4 * S; // SMuFL: 1 em = 4 staff spaces
const y0 = (p) => staffTop + p * (S / 2); // staff-position -> y
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
const glyph = (name, x, p, color, scale) => {
const cp = typeof name === "number" ? name : GLYPH[name];
ctx.fillStyle = color;
ctx.font = (em * (scale || 1)) + "px Bravura";
// SMuFL glyphs sit on the baseline = the reference staff line; fillText baseline aligns there.
ctx.fillText(chr(cp), x, y0(p));
};
const line = (x1, yy1, x2, yy2, color, w) => {
ctx.strokeStyle = color; ctx.lineWidth = w || 1;
ctx.beginPath(); ctx.moveTo(x1, yy1); ctx.lineTo(x2, yy2); ctx.stroke();
};
// geometry + model
const m = 14;
const clefW = em * 0.6;
const x1 = W - m;
const staffTop = 56;
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
const onStaff = lanes.filter((l) => !l.poly); // first non-poly lane = master (defines the meter)
const groups = (onStaff[0] && onStaff[0].groups && onStaff[0].groups.length) ? onStaff[0].groups : [4];
const beats = groups.reduce((a, b) => a + b, 0) || 4;
// time signature: additive numerator (2+2+3) for grouped meters; PM's beat is the quarter -> denom 4.
const tsDigit = em * 0.4;
const numParts = groups.length > 1 ? groups : [beats];
const numGlyphs = numParts.reduce((a, n) => a + String(n).length, 0) + (numParts.length - 1);
const tsX = m + clefW;
const tsW = Math.max(numGlyphs, 1) * tsDigit;
// notes start clear of the time signature, or at a shared gutter (so all views' beats line up)
const x0 = model.gutter != null ? model.gutter : tsX + tsW + 14;
const barW = Math.max(1, x1 - x0);
// ---- header: name + BPM ----
ctx.textAlign = "left";
ctx.font = "600 15px system-ui, sans-serif";
ctx.fillStyle = pal.ink;
ctx.fillText(model.name || "", m, 26);
ctx.textAlign = "right";
ctx.font = "700 18px 'Courier New', monospace";
ctx.fillStyle = pal.accent;
ctx.fillText((model.bpm | 0) + " BPM", x1, 26);
ctx.textAlign = "center";
// ---- staff + barlines + clef + time signature ----
for (let i = 0; i < 5; i++) line(m, staffTop + i * S, x1, staffTop + i * S, pal.ink, 1);
line(m, staffTop, m, staffTop + 4 * S, pal.ink, 1.5);
line(x1, staffTop, x1, staffTop + 4 * S, pal.ink, 1.5);
glyph("clef", m + clefW * 0.5, 4, pal.ink); // percussion clef centered on middle line (p=4)
drawTimeSig(tsX, tsW, numParts, tsDigit);
// ---- time grid: lcm of ALL lanes' step counts (incl. polyrhythm `~` lanes) → the right common
// time scale so every voice, including cross-rhythms, sits on ONE staff at aligned columns ----
let res = 1;
for (const l of lanes) res = lcm(res, l.levels.length);
res = Math.max(res, 1);
const beamable = res / Math.max(beats, 1) >= 2; // bar has subdivisions → beam within beats
ctx.font = em + "px Bravura";
const headHalf = ctx.measureText(chr(GLYPH.black)).width / 2; // real notehead half-width → stems touch it
// beaming state per stem direction (carry previous column's stem x within a beat)
let upPrev = null, dnPrev = null;
const upTip = staffTop - S * 2.6, dnTip = staffTop + 4 * S + S * 2.6;
for (let c = 0; c < res; c++) {
const cx = x0 + (c + 0.5) * barW / res;
const beat = Math.floor(c * beats / res);
let up = null, dn = null; // {loP, hiP, sub2} accumulators per direction
for (const l of lanes) {
const steps = l.levels.length;
if ((c * steps) % res !== 0) continue; // no note for this lane at this column
const si = (c * steps / res) | 0;
const lvl = l.levels[si] | 0;
if (!lvl) continue;
const orn = (l.orns && l.orns[si]) | 0;
const vc = voice(l.sound);
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
// ghost = parenthesized notehead
if (lvl === 3) glyph("parenL", cx - S * 0.85, vc.p, color);
const head = vc.head === "x" ? "x" : vc.head === "circleX" ? "circleX" : "black";
glyph(head, cx, vc.p, color);
if (lvl === 3) glyph("parenR", cx + S * 0.85, vc.p, color);
// accent mark above/below the staff edge
if (lvl === 2) glyph(vc.up ? "accentA" : "accentB", cx, vc.up ? -2 : 10, color, 0.8);
// ornaments: flam = slashed grace note up-left; roll = tremolo strokes on the stem
if (orn === 1) glyph("graceSlash", cx - S * 1.4, vc.p - 0.5, color, 0.7);
else if (orn === 2) { glyph("graceSlash", cx - S * 1.9, vc.p - 0.5, color, 0.7); glyph("graceSlash", cx - S * 1.1, vc.p - 0.5, color, 0.7); }
else if (orn === 3) glyph("trem3", cx + (vc.up ? S * 0.55 : -S * 0.55), vc.p + (vc.up ? -2 : 2), color, 0.8);
// ledger lines
for (let lp = -2; lp >= vc.p; lp -= 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
for (let lp = 10; lp <= vc.p; lp += 2) line(cx - S * 0.9, y0(lp), cx + S * 0.9, y0(lp), pal.ink, 1);
const sub2 = beamable;
const acc = vc.up ? (up = up || { loP: -99, hiP: 99, sub2: false }) : (dn = dn || { loP: -99, hiP: 99, sub2: false });
acc.loP = Math.max(acc.loP, vc.p); // lowest (largest p)
acc.hiP = Math.min(acc.hiP, vc.p); // highest (smallest p)
acc.sub2 = acc.sub2 || sub2;
}
// shared up-stem (hands): right side of head, from lowest head up past the highest
if (up) {
const sx = cx + headHalf;
const top = Math.min(upTip, y0(up.hiP) - S * 1.2);
line(sx, y0(up.loP), sx, top, pal.ink, 1.4);
if (up.sub2 && upPrev && upPrev.beat === beat) line(upPrev.x, top, sx, top, pal.ink, 3);
else if (up.sub2) {} // first of a beam group; flag drawn only if it stays solo (handled below)
upPrev = up.sub2 ? { x: sx, beat, y: top } : null;
} else upPrev = null;
// shared down-stem (feet): left side
if (dn) {
const sx = cx - headHalf;
const bot = Math.max(dnTip, y0(dn.loP) + S * 1.2);
line(sx, y0(dn.hiP), sx, bot, pal.ink, 1.4);
if (dn.sub2 && dnPrev && dnPrev.beat === beat) line(dnPrev.x, bot, sx, bot, pal.ink, 3);
dnPrev = dn.sub2 ? { x: sx, beat, y: bot } : null;
} else dnPrev = null;
}
// ---- tuplet number: the common subdivision per beat (3=triplet, 6=sextuplet, 5, 7…). For beamed
// groups the modern convention is just the numeral over the beam (no bracket). ----
const isPow2 = (n) => n > 0 && (n & (n - 1)) === 0;
const tupN = Math.round(res / Math.max(1, beats));
if (tupN >= 3 && !isPow2(tupN)) {
ctx.fillStyle = pal.ink; ctx.textAlign = "center";
ctx.font = "italic 600 13px Georgia, 'Times New Roman', serif";
const ty = upTip - S * 0.7;
for (let b = 0; b < beats; b++) ctx.fillText(String(tupN), x0 + (b + 0.5) * barW / beats, ty);
}
// ---- playhead ----
// `phase` is the master-bar fraction (0..1). Noteheads sit at column CENTERS ((c+0.5)/res), so
// shift the line by half a cell to land exactly on the note at its onset instead of leading it.
if (model.playing && model.phase != null) {
const pf = Math.max(0, Math.min(1, model.phase + 0.5 / res));
const px = x0 + pf * barW;
ctx.save(); ctx.globalAlpha = 0.55;
line(px, staffTop - S, px, staffTop + 4 * S + S, pal.play, 2);
ctx.restore();
}
// ---- hit map for on-staff editing (all in CSS px) ----
// Each on-staff lane exposes its staff row (p) + column geometry; the page maps a click to the
// nearest voice row (y) and the column (x) → (laneIndex, step). `idx` indexes model.lanes/meters.
canvas._hit = {
kind: "staff", staffTop, S, x0, barW,
lanes: lanes.map((l) => ({ idx: l.idx, p: voice(l.sound).p, steps: l.levels.length })),
};
// time signature: numerator (additive parts joined by timeSigPlus) over a quarter-note denominator,
// each row centered within the reserved width `tw`. Defined here so it closes over glyph()/em/pal.
function drawTimeSig(tx, tw, parts, dw) {
drawSigRow(tx, tw, 2, parts, dw); // numerator in the upper half of the staff
drawSigRow(tx, tw, 6, [4], dw); // denominator (PM beat = quarter) in the lower half
}
function drawSigRow(tx, tw, p, parts, dw) {
const seq = [];
parts.forEach((n, i) => {
if (i) seq.push(GLYPH.sigPlus);
String(n).split("").forEach((d) => seq.push(GLYPH.sig[+d]));
});
let xx = tx + (tw - seq.length * dw) / 2 + dw / 2;
for (const cp of seq) { glyph(cp, xx, p, pal.ink, 0.92); xx += dw; }
}
}
function drawPolyRow(ctx, glyph, line, pal, l, x0, x1, py, S) {
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
ctx.fillText(l.sound + " ~", x0, py - S * 1.6); ctx.textAlign = "center";
line(x0, py, x1, py, pal.faint, 1);
const steps = l.levels.length, barW = x1 - x0;
const vc = voice(l.sound);
for (let i = 0; i < steps; i++) {
const lvl = l.levels[i] | 0; if (!lvl) continue;
const cx = x0 + (i + 0.5) * barW / steps;
const color = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
const head = vc.head === "x" ? "x" : "black";
ctx.fillStyle = color; ctx.font = (4 * S) + "px Bravura";
ctx.fillText(chr(vc.head === "x" ? GLYPH.x : GLYPH.black), cx, py);
}
}
// ---- shared setup for the alternate views ----
function begin(canvas) {
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const W = canvas.clientWidth, H = canvas.clientHeight;
if (canvas.width !== W * dpr || canvas.height !== H * dpr) { canvas.width = W * dpr; canvas.height = H * dpr; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
return { ctx, W, H, pal: palette() };
}
function header(ctx, W, model, pal) {
ctx.textAlign = "left"; ctx.font = "600 15px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
ctx.fillText(model.name || "", 14, 26);
ctx.textAlign = "right"; ctx.font = "700 18px 'Courier New', monospace"; ctx.fillStyle = pal.accent;
ctx.fillText((model.bpm | 0) + " BPM", W - 14, 26);
ctx.textAlign = "center";
}
// Son/rumba clave fingerprint: split the bar in half, count hits each side → 2-3 or 3-2.
function claveLabel(l) {
if (!/^clave/.test(l.sound || "")) return "";
const n = l.levels.length, h = n >> 1;
if (!h) return "(clave)";
const a = l.levels.slice(0, h).filter((v) => v > 0).length;
const b = l.levels.slice(h).filter((v) => v > 0).length;
return a === 2 && b === 3 ? "(2-3)" : a === 3 && b === 2 ? "(3-2)" : "(clave)";
}
// ---- TUBS (Time Unit Box System): rows = voices, columns = time units, filled boxes = hits ----
// All bar-sharing lanes are drawn on ONE common time grid (lcm of their step counts) so every
// column lines up vertically across rows; a coarser lane just fills every Nth cell. Polymeter
// lanes keep their own spacing across the full width (that IS the cross-rhythm).
function drawTUBS(canvas, model) {
const { ctx, W, H, pal } = begin(canvas);
header(ctx, W, model, pal);
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
if (!lanes.length) { canvas._hit = null; return; }
const m = 14, x0 = model.gutter != null ? model.gutter : m + 96, x1 = W - m, gw = Math.max(1, x1 - x0);
const top = 42, bot = H - 12;
const rowH = Math.min(40, Math.max(20, (bot - top) / lanes.length));
// common grid = lcm of ALL lanes' step counts so columns line up; each lane draws ONE box per
// REAL step at its grid column → aligned AND each box is a clickable step.
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length);
res = Math.max(res, 1);
const cw = gw / res, bs = Math.max(11, Math.min(rowH - 8, cw - 3, 30));
const master = lanes.find((l) => !l.poly) || lanes[0];
const mg = master.groups && master.groups.length ? master.groups : [4];
const mbeats = mg.reduce((a, b) => a + b, 0) || 1;
const starts = new Set(); let acc = 0; for (const g of mg) { starts.add(acc); acc += g; }
const yA = top - 4, yB = top + lanes.length * rowH;
// beat / group dividers (group starts brighter)
for (let b = 0; b <= mbeats; b++) {
const lx = x0 + (b * res / mbeats) * cw;
ctx.strokeStyle = (b < mbeats && starts.has(b)) || b === 0 || b === mbeats ? pal.ghost : pal.faint;
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, yA); ctx.lineTo(lx, yB); ctx.stroke();
}
// playhead column (aligned to box centers like the staff)
if (model.playing && model.phase != null) {
const px = x0 + Math.max(0, Math.min(1, model.phase + 0.5 / res)) * gw;
ctx.save(); ctx.globalAlpha = 0.5; ctx.strokeStyle = pal.play; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(px, yA); ctx.lineTo(px, yB); ctx.stroke(); ctx.restore();
}
const rows = [];
lanes.forEach((l, r) => {
const cy = top + r * rowH + rowH / 2;
ctx.textAlign = "left"; ctx.font = "12px system-ui, sans-serif"; ctx.fillStyle = pal.ink;
ctx.fillText(l.sound + (l.poly ? " ~" : "") + (claveLabel(l) ? " " + claveLabel(l) : ""), m, cy + 4);
const steps = l.levels.length, span = res / steps;
rows.push({ idx: l.idx, steps, span });
for (let i = 0; i < steps; i++) {
const cx = x0 + (i * span + 0.5) * cw, lvl = l.levels[i] | 0;
ctx.strokeStyle = pal.faint; ctx.lineWidth = 1; ctx.strokeRect(cx - bs / 2, cy - bs / 2, bs, bs);
if (lvl > 0) {
ctx.fillStyle = lvl === 2 ? pal.accent : lvl === 3 ? pal.ghost : pal.ink;
const p = bs * 0.2; ctx.fillRect(cx - bs / 2 + p, cy - bs / 2 + p, bs - 2 * p, bs - 2 * p);
const orn = (l.orns && l.orns[i]) | 0; // flam/drag/roll marker inside the box
if (orn) { ctx.fillStyle = pal.bg; ctx.font = "700 9px system-ui, sans-serif"; ctx.textAlign = "center"; ctx.fillText(orn === 1 ? "f" : orn === 2 ? "d" : "z", cx, cy + 3.5); ctx.textAlign = "left"; }
}
}
});
// hit map for editing: click a box to cycle dynamic; Shift-click cycles ornament
canvas._hit = { kind: "tubs", x0, cw, top, rowH, rows };
}
// ---- Konnakol: spoken-rhythm syllables (solkattu) for the master lane's subdivision ----
const SOLKATTU = {
1: ["ta"], 2: ["ta", "ka"], 3: ["ta", "ki", "ta"], 4: ["ta", "ka", "di", "mi"],
5: ["ta", "ka", "ta", "ki", "ta"], 6: ["ta", "ki", "ta", "ta", "ki", "ta"],
7: ["ta", "ka", "ta", "ki", "ta", "ki", "ta"], 8: ["ta", "ka", "di", "mi", "ta", "ka", "ju", "nu"],
};
function drawKonnakol(canvas, model) {
const { ctx, W, H, pal } = begin(canvas);
canvas._hit = null;
header(ctx, W, model, pal);
const lanes = (model.lanes || []).filter((l) => !l.muted && l.levels && l.levels.length);
const m0 = lanes.find((l) => !l.poly) || lanes[0];
if (!m0) return;
const groups = m0.groups && m0.groups.length ? m0.groups : [4];
const beats = groups.reduce((a, b) => a + b, 0) || 1;
let res = 1; for (const l of lanes) res = lcm(res, l.levels.length); // common (finest) grid
const sub = Math.max(1, Math.round(res / beats)); // nadai = subdivisions per beat
const bols = SOLKATTU[sub] || SOLKATTU[4];
const m = 14, x0 = model.gutter != null ? model.gutter : m, x1 = W - m, gw = x1 - x0, colW = gw / (beats * sub), cy = H / 2;
const starts = new Set(); let acc = 0; for (const g of groups) { starts.add(acc); acc += g; }
ctx.textAlign = "center";
for (let b = 0; b < beats; b++) {
const gs = starts.has(b), sam = b === 0;
ctx.font = "10px system-ui, sans-serif"; ctx.fillStyle = pal.faint;
ctx.fillText(sam ? "X" : gs ? "O" : "·", x0 + (b * sub + 0.5) * colW, cy - 22); // sam / anga / beat
for (let s = 0; s < sub; s++) {
const idx = b * sub + s, cx = x0 + (idx + 0.5) * colW;
ctx.font = (s === 0 ? "600 " : "") + "16px system-ui, sans-serif";
ctx.fillStyle = sam && s === 0 ? pal.accent : s === 0 ? pal.ink : pal.ghost;
ctx.fillText(bols[s % bols.length], cx, cy + 6);
}
}
for (let b = 0; b <= beats; b++) { // beat/group dividers
const lx = x0 + b * sub * colW;
ctx.strokeStyle = starts.has(b) || b === 0 || b === beats ? pal.ghost : pal.faint;
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(lx, cy - 30); ctx.lineTo(lx, cy + 22); ctx.stroke();
}
ctx.textAlign = "left"; ctx.font = "11px system-ui, sans-serif"; ctx.fillStyle = pal.ghost;
ctx.fillText("tala " + groups.join("+") + " · nadai " + sub + " · X=sam O=anga", m, H - 12);
}
return { draw, GLYPH, voice };
})();