When we added a piano game to GGAMES.MOBI with 100 playable songs, we needed sheet music that could render any piece—from Twinkle Twinkle to Bach’s Prelude in C—in real time on a canvas, and also export a print-ready PDF via SVG. Libraries like VexFlow exist, but they are large, opinionated, and not designed for a scrolling falling-notes view. So we built one from scratch. This post walks through the hardest problems we solved.
The Foundation: Staff Positions
Everything in music notation starts with the staff: five lines and four spaces. We assign each vertical position an integer. For the treble clef, the bottom line (E4) is position 2, and each line or space above increments by 1. Middle C (C4) is position 0. This means a note’s name maps to a deterministic Y coordinate:
// Map note name to staff position (diatonic)
// C4=0, D4=1, E4=2, F4=3, G4=4, A4=5, B4=6, C5=7 ...
function noteToStaffPos(noteName) {
var letter = noteName.charAt(0);
var octave = parseInt(noteName.match(/-?\d+$/)[0]);
var LETTERS = {C:0, D:1, E:2, F:3, G:4, A:5, B:6};
return (octave - 4) * 7 + LETTERS[letter];
}
// Convert position to pixel Y on canvas/SVG
function posToY(pos) {
return STAFF_TOP_Y + (TOP_POSITION - pos) * STEP;
}
The STEP constant is the pixel distance between two staff positions (one line or one space). Everything scales from this single value—noteheads, stems, beams, accidentals, ledger lines. Change STEP and the entire engraving resizes proportionally.
Noteheads: Duration Encoding
A notehead’s appearance encodes its duration. Filled noteheads are used for quarter notes and shorter. Open (hollow) noteheads indicate half and whole notes. A dot adds 50% to the duration. The classification is a simple cascade of thresholds:
var isWhole = beats >= 3.5; // open, no stem
var isDottedHalf = beats >= 2.5; // open, with dot
var isHalf = beats >= 1.8; // open, no dot
var isDottedQtr = beats >= 1.2; // filled, with dot
var isEighth = beats >= 0.35; // filled, with flag or beam
var isSixteenth = beats < 0.35; // filled, with two flags or beams
var isOpen = isWhole || isHalf || isDottedHalf;
var hasDot = isDottedHalf || isDottedQtr;
The tricky case is non-standard durations like 1.75 beats (a note that starts on the second sixteenth of a beat and sustains for the rest of a half-bar). No single notehead represents 1.75 beats. The solution: split it into valid durations and connect them with ties. We cover that later.
Stems: Direction Rules
Stem direction follows a simple rule: notes above the middle line of the staff point down; notes below point up. For chords, the direction is based on the highest and lowest notes in the entire group:
var staffMid = 6; // B4 for treble, D3 for bass
// For single notes:
var stemDown = notePos >= staffMid;
// For chords (or beam groups):
var groupMax = Math.max.apply(null, allPositions);
var groupMin = Math.min.apply(null, allPositions);
var stemDown = ((groupMax + groupMin) / 2) >= staffMid;
When the average of the outer notes is above the middle line, stems go down. This keeps the stems pointing toward the center of the staff, which is both conventionally correct and visually balanced.
Beams: The Hardest Problem
Connecting consecutive eighth and sixteenth notes with beams (thick horizontal bars) is the single most complex part of notation rendering. The rules are:
- Grouping: In 4/4 time, four eighth notes in a half-bar are beamed together. But if any sixteenth notes are present, the group narrows to per-beat to avoid ambiguity.
- Primary beam: A thick rectangle connecting all stems in the group, slanted to follow the melodic contour.
- Secondary beams: Sixteenth notes get a second beam below the primary. But only consecutive sixteenths share a secondary beam—if an eighth note sits between two sixteenths, the secondary beam breaks.
- Stem length: Every stem must extend to the beam line. Intermediate notes with shorter or longer positions need their stems adjusted so all endpoints are collinear.
Here is how we compute the beam slope and adjust stem endpoints:
var first = stems[0], last = stems[stems.length - 1];
var beamY1 = stemDown ? first.stemBotY : first.stemTopY;
var beamY2 = stemDown ? last.stemBotY : last.stemTopY;
var slope = (beamY2 - beamY1) / (last.x - first.x);
// Translate beam so it clears every intermediate notehead
for (var i = 1; i < stems.length - 1; i++) {
var s = stems[i];
var beamAtS = beamY1 + slope * (s.x - first.x);
var needed = stemDown
? (s.noteheadBotY - beamAtS) // stem extends down
: (beamAtS - s.noteheadTopY); // stem extends up
if (needed > maxShift) maxShift = needed;
}
beamY1 += stemDown ? maxShift : -maxShift;
beamY2 += stemDown ? maxShift : -maxShift;
The secondary beam logic walks the stems to find consecutive runs of sixteenth notes. A pattern like [16th, 16th, 8th, 16th, 16th] produces two separate secondary beam segments with a gap at the eighth, clearly indicating the shorter duration in the middle.
Ties: Non-Valid Durations and Bar Lines
Ties connect two noteheads of the same pitch, indicating a sustained duration. We need ties in two situations:
1. Notes that cross bar lines. A half note starting on beat 3.5 in 4/4 time extends past the bar boundary. We split it into two segments—one per bar—and draw a tie between them.
2. Notes with non-standard durations. A 1.75-beat note cannot be represented by a single notehead. We split it into valid pieces (e.g., dotted quarter + sixteenth = 1.5 + 0.25) and tie them together.
var VALID_DURATIONS = [4, 3, 2, 1.5, 1, 0.75, 0.5, 0.25];
function splitIntoValidDurations(d) {
var pieces = [], remaining = d;
while (remaining > 0.01) {
for (var i = 0; i < VALID_DURATIONS.length; i++) {
if (VALID_DURATIONS[i] <= remaining + 0.01) {
pieces.push(VALID_DURATIONS[i]);
remaining -= VALID_DURATIONS[i];
break;
}
}
}
return pieces;
}
// 1.75 beats → [1.5, 0.25] → dotted quarter tied to sixteenth
// 3.5 beats → [3, 0.5] → dotted half tied to eighth
Each segment becomes a separate notehead in the sheet view, with a curved tie arc connecting consecutive segments. The tie curves away from the stem direction—upward for stem-down notes, downward for stem-up notes.
Accidentals and Key Signatures
Accidentals (sharps, flats, naturals) must respect the key signature. If a piece is in G major (one sharp, F#), then every F is implicitly sharp. We only draw an explicit accidental when the note differs from what the key signature dictates.
The algorithm processes each bar independently. Within a bar, once an accidental appears, it persists for the rest of that bar:
// Per-bar accidental annotation
function annotateAccidentals(items, keySharps, keyFlats) {
var seen = {}; // letter → accidental in this bar
items.forEach(function(it) {
var letter = it.displayLetter;
var inKeySig = keySharps.indexOf(letter) >= 0
|| keyFlats.indexOf(letter) >= 0;
if (it.needsAccidental && !inKeySig && !seen[letter]) {
it.displayAccidental = it.accidentalType; // 'sharp'|'flat'|'natural'
seen[letter] = it.accidentalType;
} else if (inKeySig && !it.isSharp && !keyFlats.length) {
// Natural needed: letter is in key sig but note is natural
it.displayAccidental = 'natural';
seen[letter] = 'natural';
}
});
}
For second-interval chords (two adjacent positions like C and D), the upper note is shifted horizontally to avoid overlap. Accidentals on shifted notes need extra horizontal offset so they do not collide with the adjacent notehead.
Chord Symbols: Auto-Detection
Lead sheets show chord symbols above the staff (C, G7, Am, Fmaj7). Rather than hand-annotate every chord for every song, we auto-detect them from the left-hand accompaniment:
- Segment the LH notes by pedal changes (each pedal-down typically marks a new chord).
- Collect pitch classes (mod 12) for each segment.
- Template-match against known chord types: major, minor, diminished, dominant 7th, major 7th, minor 7th, sus4, augmented.
- Identify the bass (lowest note) for slash chords like C/E.
- Score candidates with tie-breakers: root-position bonus, chord-type commonality (major beats diminished), and diatonic preference.
var CHORD_TEMPLATES = {
major: [0, 4, 7],
minor: [0, 3, 7],
diminished: [0, 3, 6],
dominant7: [0, 4, 7, 10],
major7: [0, 4, 7, 11],
minor7: [0, 3, 7, 10],
sus4: [0, 5, 7],
aug: [0, 4, 8]
};
// For each candidate root (0-11), check how many template
// intervals are present in the pitch class set
function identifyChord(pitchClasses, bass) {
var best = null;
for (var root = 0; root < 12; root++) {
for (var type in CHORD_TEMPLATES) {
var intervals = CHORD_TEMPLATES[type];
var matched = intervals.filter(function(iv) {
return pitchClasses.indexOf((root + iv) % 12) >= 0;
}).length;
var completeness = matched / intervals.length;
// ... scoring with bonuses for bass=root, type, diatonic
}
}
return best;
}
This approach correctly identifies chords for most classical and popular music. The main limitation: if the root is absent from the LH arpeggio (e.g., a C7 chord voiced as E-G-Bb without C), the detection may misidentify it.
Two Rendering Targets
We render the same notation to two completely different targets:
Canvas (live view): A scrolling falling-notes display where notes approach a playhead line in real time. The player hits keys as notes arrive. This uses requestAnimationFrame with a game loop, and notes fade in/out at the edges of the visible window. The beam groups, stems, and accidentals all update every frame as positions scroll.
SVG (print view): A static, paginated layout designed for A4 paper. Systems are packed greedily—each system gets as many bars as fit within the printable width. Each system is a self-contained <svg> element with its own clef, key signature, and time signature header. CSS @media print handles page breaks, and max-width:100% scales wide systems to fit the page.
The core notation logic (staff positions, beam grouping, tie metadata) is shared between both targets. Only the drawing primitives differ: ctx.fillRect() vs <rect> elements.
Print Layout: Greedy Bar Packing
For the SVG print view, we need to fit bars into systems optimally. Each bar has a minimum pixel width based on its note content. The algorithm greedily adds bars to the current system until the next bar would overflow:
var systems = [], curSys = [], curW = 0;
bars.forEach(function(bar, i) {
var barWidth = computeBarMinWidth(bar) + PADDING;
if (curW + barWidth > MAX_CONTENT_WIDTH && curSys.length > 0) {
systems.push(curSys);
curSys = [];
curW = 0;
}
curSys.push(bar);
curW += barWidth;
});
if (curSys.length > 0) systems.push(curSys);
Within each system, bars are sized proportionally to their content density. A bar with 16 sixteenth notes gets more horizontal space than a bar with one whole note. This produces sheet music that reads naturally—dense bars are wider, sparse bars are narrower.
Lessons Learned
- Staff positions are the universal abstraction. Once you map every note to a position integer, every layout question (stem direction, ledger lines, accidentals, beam slope) becomes simple arithmetic.
- Beam grouping is deceptively hard. The rules for when to beam notes together, when to break beams, and how to draw secondary beams for mixed durations took multiple iterations to get right. The key insight: group by half-bar for pure eighth notes, but narrow to per-beat when any sixteenth is involved.
- Ties solve everything. Any duration that does not map to a standard note value can be split into pieces that do, connected by ties. This handles bar-crossing notes, non-standard rhythms, and even editorial decisions (like showing a rest instead of a dotted note for readability).
- Two targets, one engine. Sharing the notation logic between Canvas and SVG saved enormous duplication. The only target-specific code is the final draw call.
- Chord auto-detection is 80% right. Template matching from LH arpeggios works for most tonal music. The 20% edge cases (rootless voicings, chromatic chords) would require manual annotation or a more sophisticated analyzer.
Building a music notation engine from scratch was one of the most technically challenging projects on this site. But the result is a rendering pipeline that handles 100 songs across multiple time signatures and key signatures, produces both interactive and printable output, and fits in a single file with zero dependencies. You can see it in action on the piano game—toggle the sheet music view and try the Download button for the print-ready version.