/* ========================================================================= 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 , 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 }; })();