An offline-first chord & lyric performance app for gigging musicians — the entire application is one hand-written HTML file you own, host, and control.
▶ Live app · User Guide · Features · Installation · Usage · Architecture · Contributing
SetList69 stores songs (lyrics with chords), organizes them into setlists, and renders them in a large, high-contrast reading view designed to be operated while holding an instrument. It imports your existing library from OnSong and other chord apps and keeps everything on your device — no subscription, no account, no cloud.
The problems it solves:
| Problem | How SetList69 answers it |
|---|---|
| Chord apps stop working when the venue has no signal | Full offline operation via service worker; every asset is precached |
| Screens sleep mid-verse | Wake Lock held for the entire set, not just one song |
| Chord charts wrap badly on phones | Custom rendering engine: chords lock to their syllable, words never split across lines, no horizontal scrolling — ever |
| Moving your songs between apps is a hassle | Imports from OnSong and other apps — ChordPro, OnSong, OpenSong XML, plain text, and ZIP bundles; exports plain JSON |
| Cloud accounts, subscriptions, telemetry | None. All data stays in the browser's local storage; sync between devices is a file you control |
Why it stands out: the whole app is a single setlist69.html — no framework, no build step, no node_modules. You can read every line, fork it, and host it on any static server. Reliability, readability while playing, offline operation, and ownership are the four design priorities, in that order.
| Area | Capability |
|---|---|
| Rendering | Chords positioned above the exact syllable; color-coded by root pitch; inline ChordPro ([G]word) and chords-above-lyrics formats both parse |
| Transposition | Per-song transpose (remembered), set-wide transpose, sharp/flat spelling toggle, capo support with fingering-shape display |
| Performance | Auto-scroll with live speed control and progress bar, swipe between songs, fit-to-screen font sizing, wake lock across the whole set |
| Stage safety | Stage mode hides all editing chrome; leaving a song mid-performance requires a deliberate double tap |
| Set management | Drag-to-reorder, per-set notes, duplicate sets, ✓ Played tracking that survives reloads and resets in one tap |
| Import | ChordPro (.cho/.chordpro/.pro), OnSong (.onsong/.txt incl. Key:/Capo:), OpenSong XML, plain text, ZIP bundles, paste-from-chord-sites with automatic cleanup |
| Export / share | Full library backup to JSON; single-setlist share files that merge into a bandmate's library without duplicates |
| PWA | Installable on iOS/Android, offline-first caching, in-app update notification, install nudge |
| Theming | Dark (default) and light themes, both ≥ WCAG AA contrast |
SetList69/
├── setlist69.html # The entire application — HTML, CSS, and JS in one file
├── sw.js # Service worker: cache-first, precaches all assets
├── manifest.json # PWA manifest (id, icons, screenshots, standalone)
├── index.html # Redirect stub → setlist69.html
├── fonts/ # Self-hosted WOFF2 (Fraunces, Hanken Grotesk, JetBrains Mono)
├── icons/ # App icons incl. Android-maskable variant
├── docs/
│ ├── DEVICE-TESTING.md # Manual test pass for touch/visual behavior
│ ├── shots.js # Playwright helper — regenerates README screenshots
│ └── screenshots/
└── .github/workflows/
└── check.yml # CI: syntax, version-match, duplicate-id, manifest+precache checks
Inside setlist69.html, the code reads top to bottom: persistence (IndexedDB + localStorage fallback) → seed data → music core (transposition, chord detection) → parsing/rendering engine → screen router → renderers → import/export → event wiring. A single in-memory state object holds everything and is persisted whole:
state = {
songs: [{ id, title, sub, key, capo, defaultTranspose, body }],
setlists: [{ id, name, notes, setTranspose, songIds: [...] }],
theme: "dark"
}Songs are a shared master store; setlists reference them by id, so editing a song updates every set that uses it. Deep architecture notes (rendering pipeline, capo math, merge-import rules) live in CLAUDE.md.
- To use: any modern browser. Nothing else — no account, no API keys, no install step beyond the browser.
- To develop:
git, and Node.js ≥ 18 (only for the syntax checks and screenshot tooling; the app itself has no build).
- Open cdburgess75.github.io/SetList69 — Safari on iPhone/iPad, Chrome on Android
- iPhone: Share → Add to Home Screen · Android: ⋮ → Add to Home Screen
- Launch it once with signal; it works fully offline afterward. When a new version ships, the app slides down an "Update ready" banner naming the new version (and keeps a ⟳ button in the header to apply it) — no cache clearing needed.
git clone https://github.com/cdburgess75/SetList69.git
cd SetList69
npx serve . # any static file server works; or just open setlist69.htmlDeploying your own copy is a push to any static host (GitHub Pages, Netlify, a folder on your own server).
Add a song — tap + next to Songs and paste either format:
[G]Amazing [G7]grace how [C]sweet the [G]sound ← inline ChordPro
G G7 C G
Amazing grace how sweet the sound ← chords above lyrics
Build a set — tap + next to Setlists, add songs, drag the ≡ handle to order them.
Play the gig — open the set, tap ▶ Stage, tap the first song. Swipe left for the next song; each one is crossed off behind you. The ⚙ button holds transpose, sharp/flat, font size, and fit-to-screen.
Import an existing library — ≡ menu → Import songs. Point it at .onsong/.chordpro/.txt/.xml files or a ZIP of them. From OnSong: export songs individually, zip them, import the zip.
There is no unit-test framework by design; correctness is enforced by CI checks plus targeted Node harnesses for the music core:
# What CI runs on every push:
node -e "const s=require('fs').readFileSync('setlist69.html','utf8').match(/<script>([\s\S]*?)<\/script>/)[1];require('fs').writeFileSync('/tmp/app.js',s)" \
&& node --check /tmp/app.js && node --check sw.js
# Regenerate the README screenshots after UI changes:
node docs/shots.js
# Manual touch/visual verification (real device):
# → follow docs/DEVICE-TESTING.mdRevisions use vYYYY.MM.DD.NNN. Every change bumps the version in three synced places — the changelog comment at the top of setlist69.html, the on-screen brand tag, and the CACHE constant in sw.js. CI fails the build if they drift. The full changelog lives at the top of setlist69.html.
This is a personal tool developed in the open, and issues/PRs are welcome:
- Read
CLAUDE.mdfirst — it documents the architecture, the rendering engine's invariants, and the change checklist. - Keep the constraints: vanilla JS, one file, zero runtime dependencies, no build step.
- Bump the version in all three places (CI will catch you if you don't).
node --checkthe extracted script; add a small Node harness if you touch the music core.- Flag anything needing real-device testing (touch, share sheet, install flow) in your PR description.
MIT — use it, fork it, self-host it, gig with it. Attribution is the only condition.



