// The gesture as a piano roll: each note at its onset and pitch, colored by // the string it is fingered on, with the marker height proportional to the // pluck force. For a single note this is one mark; for a walking line the // jitter and force spread are what keep it from sounding like a sequencer. import { midiName } from '../../params'; import { useCanvas, drawFrame, PLOT_COLORS } from './canvasUtils'; interface Props { noteT: Float32Array; noteMidi: Float32Array; noteStr: Float32Array; noteForce: Float32Array; durationS: number; } const STRING_COLORS = ['#4dd0e1', '#6fbf73', '#e8a33d', '#e57373']; // G D A E const STRING_NAMES = ['G', 'D', 'A', 'E']; export default function NotesPlot({ noteT, noteMidi, noteStr, noteForce, durationS }: Props) { const ref = useCanvas( (ctx, w, h) => { drawFrame(ctx, w, h); const padL = 40; const padB = 16; const pw = w - padL - 8; const ph = h - padB - 14; const top = 12; const n = noteT.length; if (n === 0) return; let mLo = Infinity, mHi = -Infinity, fMax = 0; for (let i = 0; i < n; i++) { mLo = Math.min(mLo, noteMidi[i]); mHi = Math.max(mHi, noteMidi[i]); fMax = Math.max(fMax, noteForce[i]); } mLo -= 2; mHi += 2; if (fMax <= 0) fMax = 1; const tx = (t: number) => padL + (t / Math.max(1e-6, durationS)) * pw; const my = (m: number) => top + ph - ((m - mLo) / (mHi - mLo)) * ph; ctx.font = '10px system-ui'; ctx.textAlign = 'center'; const tStep = durationS > 8 ? 2.0 : durationS > 4 ? 1.0 : durationS > 1.5 ? 0.5 : 0.1; for (let t = 0; t <= durationS + 1e-9; t += tStep) { const x = tx(t); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, top + ph); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(`${t.toFixed(1)}s`, x, h - 4); } // pitch gridlines on the octave Cs and the open strings ctx.textAlign = 'right'; for (const m of [28, 33, 36, 38, 43, 48]) { if (m < mLo || m > mHi) continue; const yy = my(m); ctx.strokeStyle = PLOT_COLORS.grid; ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + pw, yy); ctx.stroke(); ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText(midiName(m), padL - 4, yy + 3); } // note marks: a bar from onset toward the next onset, sized by force for (let i = 0; i < n; i++) { const x0 = tx(noteT[i]); const x1 = i + 1 < n ? tx(noteT[i + 1]) - 2 : x0 + Math.min(40, pw * 0.06); const yy = my(noteMidi[i]); const s = Math.round(noteStr[i]) - 1; const th = 2 + 4 * (noteForce[i] / fMax); const bw = Math.max(4, x1 - x0); ctx.fillStyle = STRING_COLORS[Math.min(3, Math.max(0, s))]; ctx.globalAlpha = 0.9; ctx.fillRect(x0, yy - th / 2, bw, th); ctx.globalAlpha = 1; if (n <= 16) { ctx.fillStyle = PLOT_COLORS.axis; ctx.textAlign = 'center'; ctx.fillText(midiName(noteMidi[i]), x0 + bw / 2, yy - 7); } } ctx.textAlign = 'left'; let lx = padL + 6; for (let s = 0; s < 4; s++) { ctx.fillStyle = STRING_COLORS[s]; ctx.fillText(`${STRING_NAMES[s]} string`, lx, top + 2); lx += 52; } ctx.fillStyle = PLOT_COLORS.axis; ctx.fillText('ยท bar height = pluck force', lx + 4, top + 2); }, [noteT, noteMidi, noteStr, noteForce, durationS], 160, ); return ; }