I Built a Real-Time Collaborative Sheet Music Editor - Here's What MusicXML and CRDTs Taught Me
I wanted a "Google Docs for sheet music": open a browser tab, start writing notation, share a URL, and have someone else edit the same score with you in real time. Nothing like that existed. MuseScore, Finale, and Sibelius are all excellent, but they're desktop apps built for people who already know notation software inside out. So I built ScoreTail, a browser-based score editor, mostly alone, with a lot of AI-assisted development. This post is about the parts that turned out to be genuinely hard: the MusicXML format, real-time collaborative editing over CRDTs, and rendering performance. The AI-driven development bet MusicXML is a huge spec - pitch, duration, voices, staves, backup/forward, divisions, ties, slurs, ornaments, tuplets, lyrics, chord symbols, dynamics, repeat structures. Implementing all of it solo, the traditional way, is a multi-year project. Late last year I made a bet: let AI (Claude Code, ~90% of the code; Antigravity, ~10%) write most of the implementation, while I focused on defining what "correct" means - architecture decisions and test cases - rather than typing every line myself. The thing that made this workable wasn't the AI, though, it was the test harness: I collected around 250 real-world scores (Bach, Beethoven, Chopin, Brahms, Debussy, plus the LilyPond regression test corpus) and ran them through an import โ export โ re-import round-trip. Any structural drift between the first and second import is a bug. AI writes code, the round-trip suite catches regressions, AI fixes them, repeat. Humans stayed in the loop for the judgment calls; the AI did the volume work. MusicXML's backup/forward problem The single hardest part of MusicXML to get right is / . MusicXML is a flat, sequential XML structure, but a measure isn't sequential - it has multiple simultaneous voices, and a piano part has two staves (treble/bass) sharing one measure. To represent that in a flat stream, the format writes voice A's notes, then emits a element to rewind the internal time cursor, then writes voice B's notes starting from the same beat. That means any code that reads or writes MusicXML has to maintain its own notion of "where am I on the timeline right now" independent of document order - and get it right across combinations of backup + multi-voice + multi-staff, which is where most real-world parser bugs live. Keeping every edit "musically valid" Early on I decided every operation had to leave the score in a musically valid state - correctness over flexibility. Concretely: if a 4/4 measure has beat 4 occupied by a quarter note, you can't just change it to a half note, because that would overflow the measure boundary. For every edit, the editor has to decide: overflow into the next measure, auto-insert a tie, or reject the operation outright. The harder cases are things like inserting a note when another voice already has something at that beat, notes of different lengths sharing a beat, or replacing a rest with a note that doesn't fill the remaining space cleanly. This constraint exists because the target user isn't a notation expert - someone who doesn't know music theory should still end up with a structurally correct score, not a fragile one. Real-time collaboration: CRDTs weren't built for this For multiplayer editing I used Yjs, a CRDT (Conflict-free Replicated Data Type) library - no server-side conflict resolution, edits from multiple clients converge automatically, the same approach Google Docs and Figma use. Text CRDTs only have to reason about character insertion and deletion in a 1D sequence. A score has two simultaneous temporal dimensions per note - its position (which beat) and its duration (how many beats it occupies) - and multiple users can be editing different voices in the same measure concurrently, or one user can be inserting a note while another is changing the measure's time signature or key signature mid-edit. My approach: map the MusicXML tree onto Yjs's Y.Map /Y.Array types 1:1. Yjs guarantees structural convergence - every client ends up with the same tree - but it has no idea what a musically valid tree looks like, so structural convergence and musical validity are two separate problems. The safety net is an auto-undo: if an export ever produces an invalid document, the last operation is automatically rolled back rather than left in a broken state. Rendering with Verovio, and the ID-mapping problem Rendering runs through Verovio, an open-source C++ engraving engine compiled to WebAssembly. Two problems showed up once scores got non-trivial in size: Performance. Full-score re-rendering on every edit blocks the main thread for large scores. Fix: move rendering into a Web Worker, and stream rendering page-by-page, prioritizing whichever page the user is currently looking at. Identity. Verovio has its own internal IDs for rendered SVG elements; Yjs has its own IDs for the underlying data. When a user clicks a note on screen, you need to resolve an SVG element back to the exact Yjs entry it came from. I ended up computing a fingerprint per note - part index, measure number, beat position, voice, pitch - and rebuilding the mapping table on each render pass. Building a notation renderer from scratch was on the table at one point; I shelved it as a multi-year project of its own and leaned on Verovio instead. Click coordinates โ beat position + pitch Turning a click into "beat X, pitch Y" is deceptively harder than the equivalent problem in a text editor. A whole note is visually wide, a sixteenth note is narrow, dotted notes are wider still, and note spacing varies measure-to-measure based on how packed the measure is - so the mapping has to be derived from Verovio's actual final layout, not from a fixed grid. For feedback, the editor shows a translucent "ghost" preview of the pending note before you commit the click: purple for a new note, blue for adding to a chord, green for replacing a rest, gray when the position conflicts with something already there. What I chose not to build Notation software has effectively unlimited surface area - tablature, drum notation, transposing instruments, automatic part extraction, fine-grained layout control, and on. As a (mostly) solo project, the operative question stayed "what helps the most people right now," and a lot got deliberately cut or deferred. Current honest state: a 5-10 minute piano score performs well; large orchestral scores are still rough. MusicXML's other trap is that the spec and what software actually emits diverge - MuseScore and Dorico export structurally different XML for the same piece. There's no shortcut around this beyond feeding the parser a large, varied corpus of real files and absorbing the "dialects" one by one. Try it ScoreTail is free, browser-based, and works without an account for a quick edit. If you've dealt with MusicXML, CRDT-based collaborative editing, or WASM rendering engines, I'd be curious to hear how you approached the same problems. Top comments (0)
Comments
No comments yet. Start the discussion.