/** * Rhythm Notation Library * Single-line percussion staff with beamed rhythm patterns for music theory exercises * Uses Bravura (SMuFL) font for rest symbols and time signatures */ class RhythmNotation { constructor(canvasId, numMeasures = 1, beatsPerMeasure = 4, isCompound = false) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); // Number of measures and time signature this.numMeasures = numMeasures; this.beatsPerMeasure = beatsPerMeasure; this.isCompound = isCompound; // Calculate sixteenths per beat and per measure this.sixteenthsPerBeat = isCompound ? 6 : 4; if (isCompound) { // 6/8 = 2 compound beats, 9/8 = 3 compound beats const compoundBeats = beatsPerMeasure === 6 ? 2 : 3; this.sixteenthsPerMeasure = compoundBeats * 6; } else { this.sixteenthsPerMeasure = beatsPerMeasure * 4; } this.totalSixteenths = numMeasures * this.sixteenthsPerMeasure; // Pattern: array of {position, duration, sixteenths} objects this.pattern = []; // Results for showing hits/misses this.results = null; // Current highlighted beat for playback this.currentBeat = null; // Platform detection this.isLinux = navigator.platform.toLowerCase().includes('linux'); this.isMobile = window.innerWidth <= 768; // Staff configuration this.startX = 60; this.staffY = 80; this.scaleFactor = 1.0; // Note configuration this.noteheadRx = 7; this.noteheadRy = 5; this.stemHeight = 30; this.stemWidth = 1.5; this.beamThickness = 4; this.beamGap = 5; this.flagWidth = 8; // Spacing calculations - account for time signature width this.clefWidth = 30; this.timeSignatureWidth = 25; this.measureWidth = (this.canvas.width - this.startX - this.clefWidth - this.timeSignatureWidth - 40) / numMeasures; this.staffWidth = this.canvas.width - this.startX - 20; this.firstNoteX = this.startX + this.clefWidth + this.timeSignatureWidth + 20; // Mobile adjustments if (this.isMobile) { this.startX = 40; this.firstNoteX = this.startX + this.clefWidth + this.timeSignatureWidth + 15; this.scaleFactor = 1.1; } // Bravura font configuration this.musicFont = 'Bravura'; this.restFontSize = 32; this.timeSignatureFontSize = 36; // SMuFL codepoints for rests (using ledger line versions for whole/half) this.REST_GLYPHS = { whole: '\uE4F4', // U+E4F4 restWholeLegerLine (with line above) half: '\uE4F5', // U+E4F5 restHalfLegerLine (with line below) quarter: '\uE4E5', // U+E4E5 restQuarter eighth: '\uE4E6', // U+E4E6 rest8th sixteenth: '\uE4E7' // U+E4E7 rest16th }; // SMuFL codepoints for time signature numerals (U+E080 = 0, U+E081 = 1, etc.) this.TIME_SIG_GLYPHS = { 0: '\uE080', 1: '\uE081', 2: '\uE082', 3: '\uE083', 4: '\uE084', 5: '\uE085', 6: '\uE086', 7: '\uE087', 8: '\uE088', 9: '\uE089' }; // Vertical offsets for each rest type (relative to staff line) this.REST_OFFSETS = { whole: -20, half: -20, quarter: -10, eighth: -14, sixteenth: -14 }; } /** * Set time signature (updates internal calculations) */ setTimeSignature(beatsPerMeasure) { this.beatsPerMeasure = beatsPerMeasure; this.sixteenthsPerMeasure = beatsPerMeasure * 4; this.totalSixteenths = this.numMeasures * this.sixteenthsPerMeasure; } /** * Get X position for a given 16th note index (across all measures) */ getNoteX(position) { const measureIndex = Math.floor(position / this.sixteenthsPerMeasure); const positionInMeasure = position % this.sixteenthsPerMeasure; const measureStartX = this.firstNoteX + (measureIndex * this.measureWidth); const measureNoteWidth = this.measureWidth - 20; // Leave room for bar line return measureStartX + (positionInMeasure / this.sixteenthsPerMeasure) * measureNoteWidth; } /** * Get the X position of a bar line */ getBarLineX(measureIndex) { return this.firstNoteX + (measureIndex * this.measureWidth) - 10; } /** * Set the rhythm pattern */ setPattern(pattern) { this.pattern = pattern; this.results = null; this.currentBeat = null; } /** * Draw the single-line percussion staff with bar lines */ drawStaff() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // Draw single staff line this.ctx.strokeStyle = '#000'; this.ctx.lineWidth = 1.5; this.ctx.beginPath(); this.ctx.moveTo(this.startX, this.staffY); this.ctx.lineTo(this.startX + this.staffWidth, this.staffY); this.ctx.stroke(); // Draw percussion clef this.drawPercussionClef(); // Draw time signature this.drawTimeSignature(); // Draw bar lines between measures for (let m = 1; m <= this.numMeasures; m++) { const barX = this.getBarLineX(m); this.ctx.lineWidth = m === this.numMeasures ? 3 : 1.5; // Thicker final bar this.ctx.beginPath(); this.ctx.moveTo(barX, this.staffY - 15); this.ctx.lineTo(barX, this.staffY + 15); this.ctx.stroke(); } } /** * Draw percussion clef (two thick vertical rectangles) */ drawPercussionClef() { this.ctx.fillStyle = '#000'; const clefX = this.startX + 8; const barWidth = 4; const barHeight = 20; const barGap = 6; this.ctx.fillRect(clefX, this.staffY - barHeight/2, barWidth, barHeight); this.ctx.fillRect(clefX + barWidth + barGap, this.staffY - barHeight/2, barWidth, barHeight); } /** * Draw time signature using Bravura glyphs */ drawTimeSignature() { const x = this.startX + this.clefWidth + 12; this.ctx.save(); this.ctx.font = `${this.timeSignatureFontSize}px ${this.musicFont}`; this.ctx.fillStyle = '#000'; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; // Numerator (beats per measure) - above the line const numerator = this.TIME_SIG_GLYPHS[this.beatsPerMeasure] || this.beatsPerMeasure.toString(); this.ctx.fillText(numerator, x, this.staffY - 12); // Denominator - 8 for compound, 4 for simple const denominatorValue = this.isCompound ? 8 : 4; const denominator = this.TIME_SIG_GLYPHS[denominatorValue]; this.ctx.fillText(denominator, x, this.staffY + 12); this.ctx.restore(); } /** * Draw a notehead */ drawNotehead(x, filled = true, color = '#000') { this.ctx.save(); this.ctx.translate(x, this.staffY); this.ctx.rotate(-0.2); if (filled) { // Filled notehead (quarter, eighth, sixteenth) this.ctx.beginPath(); this.ctx.ellipse(0, 0, this.noteheadRx, this.noteheadRy, 0, 0, Math.PI * 2); this.ctx.fillStyle = color; this.ctx.fill(); } else { // Hollow notehead with thick sides (half note) this.ctx.fillStyle = color; this.ctx.beginPath(); // Outer ellipse this.ctx.ellipse(0, 0, this.noteheadRx, this.noteheadRy, 0, 0, Math.PI * 2); // Inner ellipse - narrower horizontally for thick sides this.ctx.ellipse(0, 0, this.noteheadRx * 0.45, this.noteheadRy * 0.75, 0, 0, Math.PI * 2, true); this.ctx.fill('evenodd'); } this.ctx.restore(); } /** * Draw a stem going up from a notehead */ drawStem(x, color = '#000') { const stemX = x + this.noteheadRx - 1; const stemBottom = this.staffY - this.noteheadRy + 2; const stemTop = stemBottom - this.stemHeight; this.ctx.strokeStyle = color; this.ctx.lineWidth = this.stemWidth; this.ctx.beginPath(); this.ctx.moveTo(stemX, stemBottom); this.ctx.lineTo(stemX, stemTop); this.ctx.stroke(); return { x: stemX, top: stemTop, bottom: stemBottom }; } /** * Draw flags for a single note (unbeamed 8th or 16th) */ drawFlags(stemX, stemTop, count = 1, color = '#000') { this.ctx.fillStyle = color; for (let i = 0; i < count; i++) { const flagY = stemTop - 4 + (i * 12); // Flag shape: straight down along stem, then curves out and down this.ctx.beginPath(); this.ctx.moveTo(stemX, flagY); // Straight down along stem this.ctx.lineTo(stemX, flagY + 14); // Curve outward and downward this.ctx.bezierCurveTo( stemX + 8, flagY + 14, // control point 1 stemX + 12, flagY + 18, // control point 2 stemX + 8, flagY + 26 // end point ); // Curve back (inner edge of flag) this.ctx.bezierCurveTo( stemX + 10, flagY + 18, stemX + 6, flagY + 12, stemX, flagY + 8 ); this.ctx.closePath(); this.ctx.fill(); } } /** * Draw a beam connecting stems */ drawBeam(stems, level = 0, color = '#000') { if (stems.length < 2) return; const yOffset = level * (this.beamThickness + this.beamGap); const firstStem = stems[0]; const lastStem = stems[stems.length - 1]; this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.moveTo(firstStem.x, firstStem.top + yOffset); this.ctx.lineTo(lastStem.x, lastStem.top + yOffset); this.ctx.lineTo(lastStem.x, lastStem.top + yOffset + this.beamThickness); this.ctx.lineTo(firstStem.x, firstStem.top + yOffset + this.beamThickness); this.ctx.closePath(); this.ctx.fill(); } /** * Draw a rest using Bravura font glyph */ drawRestGlyph(x, duration, color = '#000') { const glyph = this.REST_GLYPHS[duration]; if (!glyph) return; const offset = this.REST_OFFSETS[duration] || 0; this.ctx.save(); this.ctx.font = `${this.restFontSize}px ${this.musicFont}`; this.ctx.fillStyle = color; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; // Draw the glyph centered at x, with vertical offset from staff line this.ctx.fillText(glyph, x, this.staffY + offset); this.ctx.restore(); } /** * Get the color for a note based on results */ getNoteColor(position) { if (!this.results) { return '#000'; // Black by default } const result = this.results.find(r => r.position === position); if (result) { if (result.hit === true) { return '#8BB836'; // Accessible green — matches --theory-correct in theory.css } else if (result.hit === false) { return '#C44425'; // red-400 — matches --theory-incorrect } } return '#000'; // Black for unevaluated or not found } /** * Draw a dot after a note */ drawDot(x, color = '#000') { const dotX = x + this.noteheadRx + 5; const dotY = this.staffY; this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(dotX, dotY, 2.5, 0, Math.PI * 2); this.ctx.fill(); } /** * Draw a tie arc between two note positions * Ties curve below the staff line (since all notes are on the line) */ drawTie(fromPosition, toPosition, color = '#000') { const x1 = this.getNoteX(fromPosition) + this.noteheadRx; // Right edge of first notehead const x2 = this.getNoteX(toPosition) - this.noteheadRx; // Left edge of second notehead const y = this.staffY + this.noteheadRy + 2; // Just below the notehead const midX = (x1 + x2) / 2; const distance = x2 - x1; const curveDepth = Math.min(12, distance * 0.15); // Curve depth proportional to distance this.ctx.strokeStyle = color; this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.moveTo(x1, y); this.ctx.quadraticCurveTo(midX, y + curveDepth, x2, y); this.ctx.stroke(); // Draw a slightly thicker inner curve for the tie shape this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(x1 + 2, y + 1); this.ctx.quadraticCurveTo(midX, y + curveDepth - 2, x2 - 2, y + 1); this.ctx.stroke(); } /** * Draw a single note based on duration */ drawNote(note) { const x = this.getNoteX(note.position); const color = this.getNoteColor(note.position); // Handle rests if (note.rest) { this.drawRest(note.position, note.duration, note.dotted); return; } switch (note.duration) { case 'whole': this.drawNotehead(x, false, color); break; case 'dotted-half': this.drawNotehead(x, false, color); this.drawStem(x, color); this.drawDot(x, color); break; case 'half': this.drawNotehead(x, false, color); this.drawStem(x, color); break; case 'quarter': this.drawNotehead(x, true, color); this.drawStem(x, color); break; case 'eighth': this.drawNotehead(x, true, color); const stem8 = this.drawStem(x, color); this.drawFlags(stem8.x, stem8.top, 1, color); break; case 'sixteenth': this.drawNotehead(x, true, color); const stem16 = this.drawStem(x, color); this.drawFlags(stem16.x, stem16.top, 2, color); break; } // Draw dot if dotted (for notes that use the dotted flag) if (note.dotted && note.duration !== 'dotted-half') { this.drawDot(x, color); } } /** * Draw a rest based on duration using Bravura font * Special positioning: whole rests center in measure, half rests between beats 1-2 */ drawRest(position, duration, dotted = false) { const color = '#000'; let x; // Calculate which measure this rest is in const measureIndex = Math.floor(position / this.sixteenthsPerMeasure); const measureStartX = this.firstNoteX + (measureIndex * this.measureWidth); const measureEndX = measureStartX + this.measureWidth - 20; const measureCenterX = (measureStartX + measureEndX) / 2; // Special positioning for whole and half rests if (duration === 'whole' || duration === 'dotted-half') { // Center in the measure x = measureCenterX; } else if (duration === 'half') { // Position between beats 1 and 2 (at the 1 beat mark, which is 4 sixteenths in) const beat1X = this.getNoteX(position); const beat2X = this.getNoteX(position + 4); x = (beat1X + beat2X) / 2; } else { // Normal positioning for other rests x = this.getNoteX(position); } // Handle dotted-half rest if (duration === 'dotted-half') { this.drawRestGlyph(x, 'half', color); // Draw dot for rest this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(x + 12, this.staffY - 22, 2.5, 0, Math.PI * 2); this.ctx.fill(); return; } // Use Bravura glyphs for rests this.drawRestGlyph(x, duration, color); // Draw dot if dotted if (dotted) { this.ctx.fillStyle = color; this.ctx.beginPath(); const dotOffset = this.REST_OFFSETS[duration] || 0; this.ctx.arc(x + 12, this.staffY + dotOffset - 2, 2.5, 0, Math.PI * 2); this.ctx.fill(); } } /** * Find beamable groups within beats (doesn't cross bar lines) */ findBeamableGroups() { const groups = []; const beamable = this.pattern.filter(n => !n.rest && (n.duration === 'eighth' || n.duration === 'sixteenth') ); if (beamable.length === 0) return groups; // Calculate total beats based on meter type let beatsPerMeasureActual; if (this.isCompound) { // 6/8 = 2 compound beats, 9/8 = 3 compound beats beatsPerMeasureActual = this.beatsPerMeasure === 6 ? 2 : 3; } else { beatsPerMeasureActual = this.beatsPerMeasure; } const totalBeats = this.numMeasures * beatsPerMeasureActual; for (let beat = 0; beat < totalBeats; beat++) { const beatStart = beat * this.sixteenthsPerBeat; const beatEnd = beatStart + this.sixteenthsPerBeat; const inBeat = beamable.filter(n => n.position >= beatStart && n.position < beatEnd ); if (inBeat.length >= 2) { let currentGroup = [inBeat[0]]; for (let i = 1; i < inBeat.length; i++) { const prev = inBeat[i-1]; const curr = inBeat[i]; if (curr.position === prev.position + prev.sixteenths) { currentGroup.push(curr); } else { if (currentGroup.length >= 2) { groups.push(currentGroup); } currentGroup = [curr]; } } if (currentGroup.length >= 2) { groups.push(currentGroup); } } } return groups; } /** * Draw the complete pattern */ drawPattern() { this.drawStaff(); // Find beamable groups const beamGroups = this.findBeamableGroups(); const beamedPositions = new Set(); beamGroups.forEach(group => { group.forEach(note => beamedPositions.add(note.position)); }); // Draw beamed groups beamGroups.forEach(group => { const stems = []; group.forEach(note => { const x = this.getNoteX(note.position); const color = this.getNoteColor(note.position); // Get color for EACH note this.drawNotehead(x, true, color); const stem = this.drawStem(x, color); stems.push({ ...stem, note, color }); // Draw dot if dotted if (note.dotted) { this.drawDot(x, color); } }); // Primary beam (8th note level) - use black for beam this.drawBeam(stems, 0, '#000'); // Secondary beams for 16th notes // Find consecutive runs of 16ths and beam them together let i = 0; while (i < group.length) { if (group[i].duration === 'sixteenth') { // Find the end of this 16th run let runStart = i; let runEnd = i; while (runEnd < group.length - 1 && group[runEnd + 1].duration === 'sixteenth') { runEnd++; } if (runEnd > runStart) { // Multiple consecutive 16ths - draw full secondary beam this.drawBeam(stems.slice(runStart, runEnd + 1), 1, '#000'); } else { // Single isolated 16th - draw partial beam if (i > 0) { // Has a note before - extend left toward it const partialStem = { x: stems[i].x - 10, top: stems[i].top }; this.drawBeam([partialStem, stems[i]], 1, '#000'); } else if (i < group.length - 1) { // Has a note after - extend right toward it const partialStem = { x: stems[i].x + 10, top: stems[i].top }; this.drawBeam([stems[i], partialStem], 1, '#000'); } } i = runEnd + 1; } else { i++; } } }); // Draw non-beamed notes this.pattern.forEach(note => { if (!beamedPositions.has(note.position)) { this.drawNote(note); } }); // Draw ties this.pattern.forEach(note => { if (note.tiedTo !== undefined && !note.rest) { this.drawTie(note.position, note.tiedTo); } }); // Draw current beat highlight if (this.currentBeat !== null) { this.highlightPosition(this.currentBeat); } } /** * Highlight the current playback position */ highlightPosition(position) { const x = this.getNoteX(position); this.ctx.strokeStyle = '#F2B705'; this.ctx.lineWidth = 3; this.ctx.beginPath(); this.ctx.arc(x, this.staffY, this.noteheadRx + 8, 0, Math.PI * 2); this.ctx.stroke(); } /** * Set current beat for playback highlight */ setCurrentBeat(index) { this.currentBeat = index; this.drawPattern(); } /** * Clear playback highlight */ clearHighlight() { this.currentBeat = null; this.drawPattern(); } /** * Show results */ showResults(results) { this.results = results; this.currentBeat = null; this.drawPattern(); } /** * Clear results */ clearResults() { this.results = null; this.drawPattern(); } /** * Full redraw */ render() { this.drawPattern(); } /** * Return a plain-text description of the current pattern for screen readers * Call after setPattern() to update the canvas aria-label */ getPatternDescription() { const timeSig = this.isCompound ? `${this.beatsPerMeasure}/8` : `${this.beatsPerMeasure}/4`; if (!this.pattern || this.pattern.length === 0) { return `Empty ${timeSig} staff`; } const items = this.pattern.map(n => { const dot = n.dotted ? 'dotted ' : ''; return n.rest ? `${dot}${n.duration} rest` : `${dot}${n.duration} note`; }); return `${timeSig} rhythm pattern: ${items.join(', ')}`; } /** * Update the canvas aria-label with the current pattern description * Call this after setPattern() and after showResults() */ updateAriaLabel() { this.canvas.setAttribute('aria-label', this.getPatternDescription()); } /** * Set a static aria-label (e.g. during playback or result display) */ setAriaLabel(text) { if (!this.canvas.hasAttribute('role')) { this.canvas.setAttribute('role', 'img'); } this.canvas.setAttribute('aria-label', text || this.getPatternDescription()); } // Export for use in other files if (typeof module !== 'undefined' && module.exports) { module.exports = RhythmNotation; }