- 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.)
403 lines
21 KiB
JavaScript
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 };
|
|
})();
|