Riff: A Free Guitar and Bass Curriculum as a Static Site
Day-by-day guitar and bass courses in Korean, English and Japanese. Every fretboard, staff and tab is compiled from JSON into SVG at build time, and nothing needs a server, a login or a running bill.
01Why I built it
Self-taught players are stuck between free videos with no order and private lessons that cost money every week. I wanted a structured program, course to month to week to day, that anyone could open without an account and that would cost nothing to keep online. That constraint set the architecture: no server, no database, no login, and progress that lives only in the browser. The hard part was notation. A lesson is useless if one fret number or one stem is wrong, and a diagram drawn by hand or shipped as an image cannot be checked, translated or re-themed. So every diagram in Riff is data first and SVG second.
Static files on a free CDN and progress in localStorage. No backend to pay for, patch or secure.
Pitches are computed from tuning and fret, scores are validated at build time, and nothing is drawn by hand.
Every lesson is a real HTML page in Korean, English and Japanese, linked with hreflang, not one SPA shell.
02What it does
- 10 courses (6 guitar, 4 bass) split into 324 lesson days, each with the same theory, visuals, practice and tips structure.
- Fretboard diagrams and combined staff and tablature with bends, slides, slurs, tuplets, dead notes and slap markers, for 4-, 5- and 6-string instruments and alternate tunings.
- Progress without an account: completion, per-week bars, resume where you left off, milestone messages, and progress export and import as a code or a file.
- Dark mode and an automatic green theme for bass courses, applied before first paint and to the notation itself.
- Practice tools loaded on demand: a Web Audio metronome and a multi-stem backing player with per-instrument mute.
- An installable PWA with a service worker, per-page SEO metadata, and a sitemap of 337 URLs with three language alternates each.
03How it works
Riff is split by a hard line between build time and run time. At build time a Node script reads the lesson Markdown in three languages, pulls out the score JSON blocks, validates every one, and writes one JSON file per day plus a course manifest. Astro turns the manifest into static routes for each language and renders each score to SVG: fretboards through my own renderer, staff and tab through VexFlow running inside jsdom. The output is 1,012 HTML pages on Cloudflare Pages. In the browser those pages are already complete. A small script only adds progress, completion and the lazily loaded audio tools on top.
04Engineering highlights
Correct staff notation is hard to write, so I used VexFlow, but only during the build, inside one reused jsdom document with a getBBox stub. VexFlow 5 measures glyphs with a web font and canvas, which broke under jsdom; version 4 ships baked glyph paths, so I pinned 4.2.5. Result: 966 staff-and-tab SVGs per language in static HTML, and 0 VexFlow bytes in the client bundle.
A note is open-string MIDI plus fret. Tunings resolve to the nearest octave of the standard open string, so Drop D, DADGAD and 4-, 5- and 6-string bass share one path. Guitar is written an octave up under a treble-8vb clef, key spelling looks only at the key root (the "b" in "A blues" is not a flat), stems follow the farthest-note rule, and beams break on the beat of the time signature.
324 days in three languages with 1,585 embedded scores is too much to proofread. The content script stops the build and names the file when a string or fret is out of range, a chord sits on a rest, a measure's durations do not match its time signature (dotted and tuplet values included), a technique has no renderer, or the three language files disagree on structure.
The renderer changed constantly, so pixel snapshots would have blocked every spacing fix. Instead each score has a semantic fingerprint (beat sums, onset, rest and notehead counts and techniques; string, fret and role for fretboards), compared with a committed baseline. It currently matches on 1,454 blocks.
On a 390 px phone a lesson has about 230 px for notes. Measures are packed greedily into rows using VexFlow's minimum-width calculation on throwaway copies, because that call mutates its input. Only a measure wider than a whole row falls back to horizontal scroll with a fade hint: 196 of 966 scores. The other 770 fit by wrapping alone.
Progress sits behind a wrapper that falls back to memory when storage is blocked, rebuilds a valid state from any JSON with a type guard, backs up corrupt data before resetting, and retries once after freeing space on a quota error. Theme and instrument are set by inline scripts before paint and re-applied after every View Transition.
05Quality & numbers
- Build-time validation stops a deploy on any malformed score, including measures whose note durations do not sum to the time signature.
- A semantic invariant check compares the musical content of every score with a committed baseline before renderer changes go in (1,454 blocks match).
- Accessible output: every notation SVG has role=img and an aria-label from the score title, dialogs are aria-modal, and animations respect prefers-reduced-motion.
06Decisions & trade-offs
- Static generation over a SPA or SSR. The first version was a hash-routed SPA that gave crawlers one empty shell, and SSR needs a server. Astro produces real pages per route and language; the cost is heavy lesson HTML (about 112 KB, 17 KB gzipped, for a typical day).
- No UI framework. With three views and a few bottom sheets, a virtual DOM would not pay for itself, and the interactive layer stays small enough to read in one sitting.
- Inline SVG instead of images or runtime rendering. Notation stays sharp, themeable through currentColor and readable without JavaScript; the price is page weight, which gzip mostly absorbs.
- localStorage instead of accounts. Nothing personal leaves the device and nothing has to be secured on a server; moving to another device is an explicit export code instead of sync.