How it works
Scorepad
A sheet-music editor that works like a shared doc: several people can edit the same score at once, see each other's cursors, and go back through its history. It is a static page in front of a small Supabase database, with abcjs drawing and playing the music. Below are the five ideas that make it work, each with the lines of code that carry it.
- Music notation
- Collaborative editing
- Databases & security
- Product design
One keystroke, end to end
- Press Ethe engine rewrites a few characters of ABC text
- Re-draw and playabcjs engraves the text and sounds the new note
- Save“change version 41 to this”, refused if someone beat you
- Everyone sees ita realtime event, merged into their copy
The score is a text file
Why ABC notation
MuseScore keeps a score as a tree of objects. Scorepad keeps it as ABC notation, a plain-text music format from the 1990s: K:D is the key, F2 G2 A2 are three quarter notes, | is a barline, "Am" above a note is a chord symbol. The page never holds any other copy of the music. Everything on screen, from the engraving to the piano highlights to the playback, is computed from that one string.
That choice is what makes sharing cheap. Text can be stored in one database column, put in a link, diffed, patched and merged with tools that already exist. abcjs reports the exact character range of every note it draws (startChar, endChar), so a click on the staff lands on a known piece of text. Editing a note means rewriting just that piece, and the first step is to split it into its parts:
/* Split an element's text into prefix (chord symbols, decorations, grace notes, slur/tuplet openers),
core (the note, chord or rest), duration and tail (ties, slur ends, spaces). */
var PREFIX_RE = /^(?:\s+|"[^"]*"|![^!]*!|\+[^+]*\+|\{[^}]*\}|\(\d(?::\d*){0,2}|\(|[.~HLMOPSTuv])/;
function split(text) {
var pre = "", rest = text, m;
while ((m = PREFIX_RE.exec(rest)) && m[0].length) { pre += m[0]; rest = rest.slice(m[0].length); }
m = /^(\[[^\]]*\]|[_^=]*[A-Ga-g][,']*|[zZxy])/.exec(rest);
if (!m) return null;
var core = m[0]; rest = rest.slice(core.length);
var d = /^\d*(?:\/+\d*)?/.exec(rest)[0];
return { pre: pre, core: core, dur: d, tail: rest.slice(d.length) };
}
function join(t) { return t.pre + t.core + t.dur + t.tail; }- The things that can come before a note: a chord symbol in quotes, a decoration like
!mf!, grace notes in braces, a slur or tuplet opener, or a one-letter ornament. - Peel those off into
pre, so an edit to the pitch never disturbs the chord symbol or dynamics written in front of it. - The core is a chord in brackets, a single pitch with its accidental and octave marks (
^c'), or a rest. - Then the length (
2,/,3/2) and whatever trails it, such as a tie. Each edit replaces one of the four parts andjoins them back.
Anything the toolbar doesn't offer yet (lyrics, repeats, triplets, extra voices) can be typed into the “Edit as text” box, and the music updates as you type.
Note input as text edits
Typing feels like a notation app
Musicians who use MuseScore expect a few things: press a letter and the note appears after the cursor, empty bars are full of rests that get written over, the bar closes itself when it is full, and a new note lands in the octave closest to the last one. insert() does all of that by deciding which characters to replace.
// target rest to write over: the selected rest, or the next element in this bar if it is a rest
var over = null, at;
if (el && el.kind === "rest" && (va.atRest || sel != null)) over = el;
else if (el) { var nx = nextIn(el, ["note", "rest", "bar"]); if (nx && nx.kind === "rest" && (nx.bar === el.bar || el.kind === "bar")) over = nx; }
at = over || (el ? nextIn(el) : null);- If you selected a rest, the new note writes over it.
- Otherwise it looks at what comes next in the same bar. A rest there gets written over too; the remainder stays as a shorter rest, so the bar still adds up. If the next thing is a note, the new note is inserted before it instead.
Then the pitch has to be written down. MIDI note 70 can be ^A or _B, and in F major it is just B, because the key signature already flattens it. spell() tries every nearby letter and scores how natural each spelling is:
/* Choose how to write MIDI note m: which letter, which accidental. pref +1 = sharps, -1 = flats. */
function spell(m, ctx, pref) {
var best = null, base = Math.floor((m - 60) / 12) * 7;
for (var p = base - 8; p <= base + 15; p++) {
var off = m - natMidi(p);
if (off < -1 || off > 1) continue;
var c = ctx(p), score = off === c ? 0 : off === 0 ? 1 : (off === pref ? 2 : 3);
if (!best || score < best.score) best = { p: p, off: off, score: score, acc: off === c ? "" : OFF_TXT[String(off)] };
}
return best;
}ctx(p)is the accidental this letter would get anyway, from the key signature or from an earlier note in the same bar. If that already gives the right pitch, no accidental is needed: the best score.- Next best is a natural sign, then a sharp when moving up or in a sharp key, a flat when moving down or in a flat key. So arrow-up from F in C major writes
^F, and arrow-down from B writes_B.
Accidentals that stay put
A side effect of plain text
In written music an accidental lasts until the end of the bar. That causes a subtle bug: in ^F2 G2 F2 the second F is sharp because of the first. Change the first F to G and the second one silently turns natural, even though you never touched it. Notation apps avoid this by storing pitches, not symbols. Scorepad gets the same result by checking: before an edit it records how every later note in the bar sounds, and afterwards it re-parses and pins any note that changed.
/* After an edit, notes later in the same bar may now inherit a different accidental
(ABC accidentals last until the barline). Re-parse, and pin any note that changed back to
the pitch it had before by writing its accidental out. `shift` is how far the edit moved them. */
function keepLater(abc, later, shift) {
var M = model(abc);
for (var k = later.length - 1; k >= 0; k--) { // right to left, so earlier positions stay valid
var want = later[k], el = M.byStart[want.start + shift];
if (!el || el.kind !== "note" || el.midis.join() === want.midis.join()) continue;
var b = trimEnd(abc, el), t = split(abc.slice(el.start, b));
if (!t) continue;
var parts = el.pitches.map(function (q, i) {
var m = want.midis[i] != null ? want.midis[i] : natMidi(q.p), off = m - natMidi(q.p);
return pitchText(q.p, off < -2 || off > 2 ? "" : OFF_TXT[String(off)]);
});
t.core = parts.length > 1 ? "[" + parts.join("") + "]" : parts[0];
abc = splice(abc, el.start, b, join(t));
}
return abc;
}laterholds each later note's position and MIDI pitches from before the edit. Positions move by the length difference of the edit, and anything that still sounds the same is left alone.- A note that changed gets its accidental written out explicitly, so it sounds exactly as before, whatever happens earlier in the bar.
The engine has a node test suite (tests/notation.test.js, 47 checks) that runs every edit through abcjs and compares the sounding MIDI pitches, rather than trusting the text looks right.
Two people, one score
Saving without overwriting each other
Each score row has a version number that the database bumps on every change. The page remembers base, the last version it knows the server has. A save is a conditional update: “set the music to this, but only if the version is still N”. If someone else saved first, zero rows change and nothing is lost.
function push(final) {
if (!isRemote() || !dirty || role === "view") return;
if (pushing) { clearTimeout(pushTimer); pushTimer = setTimeout(push, 300); return; }
pushing = true;
var send = text, id = doc.id, title = (E.getField(send, "T") || "Untitled score").slice(0, 200);
var who = guestName() || (user ? nameOf(user.email) : null);
var req = doc.sid ? sb.rpc("save_project_score", { c: doc.code, sid: doc.sid, new_abc: send, new_title: title, expected: base.version, who: who })
: doc.code ? sb.rpc("save_code_score", { c: doc.code, new_abc: send, new_title: title, expected: base.version, who: who })
: sb.from("scores").update({ abc: send, title: title }).eq("id", id).eq("version", base.version).select("version,updated_at,updated_by");
req.then(function (r) {
pushing = false;
if (!doc || doc.id !== id) return;
if (r.error) { offline = true; setSaved(navigator.onLine ? "Couldn't save · retrying" : "Offline · changes will save when you're back", "err"); clearTimeout(pushTimer); pushTimer = setTimeout(push, 4000); return; }
offline = false;
if (!r.data || !r.data.length) {
// someone saved first (or our access changed): merge and retry
return fetchLatest().then(function (row) {
if (!row || (doc.code && row.access !== "edit")) { setSaved("You no longer have access to edit this score", "err"); role = "view"; enterEditor(); return; }
merge(row); if (dirty) push();
});- The update only matches when
versionequals our base. People who opened the score with a share code save throughsave_code_score, which runs the same check inside the database. Saves are debounced by 0.4 s, so a burst of typing is one write. - Zero rows back means someone got there first. Fetch their version, merge, and push again.
Merging is the classic diff-and-patch trick, the same one behind Google's early collaborative editors: work out what I changed since base, and replay that change on top of their text.
function merge(row) {
if (!row || row.version <= base.version) return;
var mine = text, merged;
if (mine === base.abc || !dmp) merged = row.abc;
else merged = dmp.patch_apply(dmp.patch_make(base.abc, mine), row.abc)[0];
var selMap = sel;patch_make(base, mine)describes my edits relative to the last shared version;patch_applyreplays them on the newer text. Two people editing different bars both keep their work. The same function handles realtime events that arrive while you have unsaved edits.
Undo uses the same idea. Rolling the whole text back would erase what collaborators did in the meantime, so undo applies the inverse of your own edit as a patch:
// Undo applies the inverse of your own edit as a patch, so other people's edits since then survive.
function undoRedo(isRedo) {
if (!canEdit()) return;
var from = isRedo ? redo : undo, to = isRedo ? undo : redo, e = from.pop();
if (!e) return;
var a = isRedo ? e.before : e.after, b = isRedo ? e.after : e.before;
var next = text === a || !dmp ? b : dmp.patch_apply(dmp.patch_make(a, b), text)[0];
to.push(e);
text = next; sel = null;
changed();
toast((isRedo ? "Redo: " : "Undo: ") + (e.label || "edit"), 1200);- If nobody else has touched the score, this is just the old text. Otherwise only your change is reversed and theirs survives.
Who is in the score and which note each person has selected travel over a Supabase realtime presence channel, never through the database. Each person gets a color, and their selection is painted in it on everyone else's staff.
The database is the bouncer
Row-level security, as in Run List
The page is public and its database key is public, so the rules about who may read or change a score live in Postgres itself. Every policy asks one function for your role:
-- Your role on a score: 'owner', 'edit', 'view', or null (no access).
create or replace function public.score_role(s uuid) returns text
language sql stable security definer set search_path = public
as $$
select case
when auth.uid() is null then null
when exists (select 1 from scores where id = s and owner = auth.uid()) then 'owner'
else (select m.role from score_members m join scores sc on sc.id = m.score_id
where m.score_id = s and m.email = public.scorepad_email() and sc.deleted_at is null)
end
$$;- You are the owner, or you are on the score's member list by email (invited, or added when you opened a shared link while signed in). Anything else is no access.
“Anyone with the link” needed care: a policy saying “link-shared scores are readable” would let anyone list every link-shared score. Instead, signed-out visitors read through a function that only returns the one score whose id they already have, and signed-in visitors are added as members the first time they open the link, so turning the link off removes them again. A trigger keeps the rest honest:
create or replace function public.scores_before_update() returns trigger
language plpgsql security definer set search_path = public
as $$
begin
if auth.uid() is not null and auth.uid() is distinct from old.owner
and (new.owner is distinct from old.owner or new.link_access is distinct from old.link_access
or new.share_code is distinct from old.share_code or new.code_access is distinct from old.code_access
or new.manage_token is distinct from old.manage_token or new.project_id is distinct from old.project_id
or new.deleted_at is distinct from old.deleted_at)
and current_setting('scorepad.trash', true) is distinct from 'on' then
raise exception 'Only the owner can change sharing';
end if;
if new.project_id is distinct from old.project_id and new.project_id is not null and auth.uid() is not null
and not exists (select 1 from score_projects p where p.id = new.project_id and p.owner = auth.uid()) then
raise exception 'You can only move scores into your own projects';
end if;
if new.abc is distinct from old.abc or new.title is distinct from old.title then
new.version := old.version + 1;
new.updated_at := now();
-- signed in: your email; editing by share code: the name you gave (or Guest)
new.updated_by := coalesce(nullif(public.scorepad_email(), ''), nullif(current_setting('scorepad.who', true), ''));
insert into score_editors (score_id, who, via)
values (new.id, coalesce(new.updated_by, 'Someone'), case when public.scorepad_email() = '' then 'code' else 'account' end)
on conflict (score_id, who, via) do update set edits = score_editors.edits + 1, last_at = now();
if old.abc <> '' and not exists (
select 1 from score_versions where score_id = old.id and made_at > now() - interval '10 minutes') then
insert into score_versions (score_id, abc, title, saved_by, saved_at)
values (old.id, old.abc, old.title, old.updated_by, old.updated_at);
end if;
end if;
return new;
end $$;- Editors can change the music and title, but only the owner can change who owns the score or how its link or share code works, or move it to the trash, even if they craft the request by hand. (The one exception is the trash functions for a code score with no owner, which check the creator's key instead and set a flag for this.)
- Projects: only a score's owner can move it in or out of one, and only into a project they own. (People with a project's code add scores through a function that files them under the project owner.)
- The server, not the browser, increments the version, so the conflict check in idea 04 can't be bypassed.
- Who made the change: your email when signed in, or the name you typed when editing by share code.
- The tally behind the owner's “Who's been editing” list in the Share dialog: one row per person, counting their saves. Only the owner can read it, and only this trigger writes it.
- Version history: when the music changes and no snapshot has been taken in the last 10 minutes, the previous state is copied into
score_versions. Restoring a version is an ordinary edit, so it merges and can be undone like any other.
Share codes are the way in without an account: a short code like K7QM-2PXA that people type on the Scorepad page. There's no login to check, so the code itself is the key. Signed-out visitors never touch the scores table directly; they call two functions that only ever act on the one score whose code they typed:
create or replace function public.code_score(c text)
returns table (id uuid, title text, abc text, version integer, updated_at timestamptz, updated_by text, access text, share_code text)
language sql stable security definer set search_path = public
as $$ select s.id, s.title, s.abc, s.version, s.updated_at, s.updated_by, s.code_access, s.share_code
from scores s where s.share_code = public.scorepad_norm_code(c) and length(public.scorepad_norm_code(c)) >= 6 and s.deleted_at is null $$;
-- Save a change by code. Like an ordinary save it only applies on top of the version you last saw
-- (expected); otherwise nothing is written and no rows come back, and the page merges and tries again.
create or replace function public.save_code_score(c text, new_abc text, new_title text, expected integer, who text default null)
returns table (version integer, updated_at timestamptz, updated_by text)
language plpgsql security definer set search_path = public
as $$
begin
if length(new_abc) > 2000000 then raise exception 'Score too large'; end if;
perform set_config('scorepad.who', left(coalesce(nullif(trim(who), ''), 'Guest'), 60), true);
return query
update scores s set abc = new_abc, title = left(coalesce(nullif(new_title, ''), 'Untitled score'), 200)
where s.share_code = public.scorepad_norm_code(c) and s.code_access = 'edit' and s.version = expected and s.deleted_at is null
returning s.version, s.updated_at, s.updated_by;- Reading needs the exact code. Codes are 8 characters from 31 letters and digits that can't be misread (no 0/O, 1/I/L), about 850 billion possibilities, so guessing one isn't practical. Dashes, spaces and case are ignored.
- Saving by code is the same version-checked update as a signed-in save, and only works if the code allows editing. The trigger stamps the change with the name the person typed, and history snapshots still happen, so the owner can roll back anything.
The access rules were tested in PGlite (Postgres compiled to WebAssembly) with Supabase's auth functions stubbed: owners, editors, viewers, strangers and signed-out visitors, with link sharing on and off. The test is in tests/sql.test.mjs. It found one bug before it shipped: creating a score and reading it back in the same request was blocked, because the role function could not yet see the new row.
Opening MuseScore files
MusicXML and .mscz, turned into ABC
Scorepad can start from a MuseScore file (.mscz/.mscx) or MusicXML (.mxl/.musicxml), which most notation apps export. Both .mxl and .mscz are zip files; the page unpacks them with the browser's own DecompressionStream, so there's no extra library. Each format is read into the same simple list of bars, staves, voices and notes with start times, and one writer turns that into ABC: one ABC voice per staff voice, grouped with %%score, with lyrics, ties, slurs, triplets, dynamics, hairpins, chord symbols, repeats and endings.
The fiddly part is accidentals. MusicXML says what each note is (C♯4); ABC, like printed music, says what's written, and a sharp carries on to later notes in the bar. So the writer tracks, bar by bar, what each line and space currently means and only writes an accidental when the note differs from that:
function pitchTok(p) {
var oct = p.octave + clef.shift, k = p.step + oct, cur = acc[k] != null ? acc[k] : kA[p.step], a = "";
if (p.alter !== cur || (p.shown && !p.tieStop)) { a = ACC[String(Math.max(-2, Math.min(2, p.alter)))]; acc[k] = p.alter; }
return a + letter(p.step, oct);- An octave-down treble clef (tenor parts) sounds an octave below what's written, so the note is written an octave up (
clef.shift). Then: what does this line or space mean right now, from the key signature or an earlier accidental in this bar? - Write a sharp, flat or natural only when the note differs from that, or when the original showed a courtesy accidental. A note tied over a barline gets its accidental again, since the new bar starts fresh.
Lyrics need the same care. ABC lines up a w: line with the notes one syllable each, so a note that continues a tie or a held syllable gets _, a note with no word gets *, and each bar's syllables end with | so one slip can't shift the rest of the line. Checked on a 7-part choir score with Chinese lyrics, opened from both its .mscz and its MusicXML export: the two ABC files come out identical, all 1,706 notes play back at the pitch the original has, and all 1,500 lyric syllables land under the right note. tests/import.test.js covers the same ground on small built-in examples. Going the other way, File > Download MusicXML writes the score back out for MuseScore and friends; exporting that choir score and opening it again gives back exactly the same ABC, character for character.
Layout (page and system breaks, spacing), most text styling, guitar tablature, and notes beyond ABC's reach like nested tuplets or microtones. Transposing instruments come in at concert pitch from MuseScore files and at written pitch from MusicXML. Very large scores open fine, but each edit redraws the whole score, so a 7-staff, 125-bar score takes about a second per change.
Which instrument is fastest?
Measured on canonical bars, one factor at a time
Before you hear anything, abcjs downloads a recording of every distinct pitch the score uses on that instrument, decodes the recordings into raw audio, and mixes the piece into one long buffer. When it mixes, it groups notes into kinds (same instrument, pitch, loudness and length), renders each kind once, and copies it to every place it occurs. So the wait splits into a part that depends on the instrument and a part that depends only on the score:
is the set of distinct pitches, the size of the recording for pitch , your connection's speed and the time to decode one recording. The question is which of these depend on the instrument. Measuring all 2,181 recordings of the 26 instruments in the menu answers half of it: every recording is exactly six seconds long, whatever the instrument, and only the file sizes differ.
| Instrument | Size per recording | Length | vs. typical |
|---|---|---|---|
| Acoustic bass | 59 KB | 6.0 s | 0.63× |
| Piano | 76 KB | 6.1 s | 0.81× |
| 22 others, choir and strings included | 94 KB | 6.0 s | 1.00× |
| Violin, Trombone | 115 KB | 6.0 s | 1.23× |
For the rest, each term was measured on its own with canonical test bars rather than a real song (a song mixes all the factors together). Five experiments each change one thing and hold the others fixed, with five instruments' real recordings in headless Chrome, three runs each, and a straight line fitted to each:
- A. 32 quarter notes cycling through 1, 2, 4 … 32 different pitches: the cost of each extra recording, on a local disk and on a shared 20 Mbit/s line.
- B. 4 to 128 bars of one repeated note: the cost of each second of music.
- C. 1 to 8 identical parts, 16 bars each: the cost of each extra part.
- D. 32 bars with 1 to 16 notes per bar: the cost of each extra note at a fixed length.
- E. 1,024 quarter notes whose pitches and dynamics give 2 to 744 different note kinds: the cost of each kind.
| ms per… | KB | A file (line) | A file (disk) | B second | C part | D note | E kind |
|---|---|---|---|---|---|---|---|
| Acoustic bass | 55.8 | 22.9 | 7.8 | 0.76 | 10.5 | 0.14 | 1.12 |
| Piano | 80.7 | 33.1 | 9.1 | 0.68 | 10.7 | 0.16 | 0.86 |
| Choir | 94.2 | 38.6 | 8.4 | 0.68 | 11.4 | 0.17 | 0.80 |
| Marimba | 94.2 | 38.5 | 8.6 | 0.60 | 11.5 | 0.12 | 0.89 |
| Violin | 118.5 | 49.1 | 9.6 | 0.93 | 10.7 | 0.14 | 1.04 |
Every line fits well: R² is 0.98–1.00 for A, D and E, and 0.80–1.00 for B and C. KB is the average file size over the 32 pitches used in A; the other columns are milliseconds per extra file, second of music, part, note or note kind.
Two things stand out. The download column divided by the file size is the same for every instrument, 0.41 ms per KB, which is just for a 20 Mbit/s line (0.40 ms per KB). And every other column is the same for all five instruments within the run-to-run noise, with no pattern that follows file size. So in the equation above and don't depend on the instrument, and comparing two instruments on the same score leaves only
That's the order of the Instrument menu: acoustic bass and piano first, violin and trombone last, the rest tied. For a typical score with 30 distinct pitches on a 20 Mbit/s line, the whole spread from fastest to slowest is about 0.7 s, and only the first time, because the browser keeps the recordings afterwards.
The per-unit costs above explain every test bar, but they under-predict a real score. For the 7-part choir piece 我 (770 note kinds, 1,706 notes) they add up to about 1.5 s of mixing, and it measures 2.3–2.6 s. Something in real scores, perhaps many overlapping parts of different lengths, costs more than the sum of the parts. It is the same for every instrument (no pattern with file size), so it doesn't change the ranking, but the absolute times are underestimates. Mixing is also the bigger cost for a long score, which is why Scorepad now does it in the background after your first click, so Play starts straight away whichever instrument you pick.
Read further
Where everything lives
- index.html the page: layout, styles and the database settings
- web/app.js the app: views, keyboard and piano input, playback, saving and merging, presence, sharing, history
- web/notation.js the notation engine: every edit as a change to the ABC text (runs in node for tests too)
- web/export.js saving files: MusicXML (.musicxml/.mxl) and the paged PDF, written without libraries (MusicXML part runs in node for tests)
- web/import.js opening files: unzips .mxl/.mscz, reads MusicXML and MuseScore, writes ABC (runs in node for tests too)
- tests/ the engine and import tests (node) and the access-rule tests (PGlite)
- setup.sql tables, access rules, triggers and realtime setup for Supabase
- vendor/ abcjs 6.6.4 (engraving and playback, MIT) and diff-match-patch (Apache 2.0)