Blog

Notes from building it

Mostly about the hard parts: clocks, phones that disagree, and what it takes to make a room sound like one speaker. Tap any of them to read the whole thing.

3 September 2026

A party belongs near the people in it

The distance problem in this app was never bandwidth. It is the clock.

Read the post

Every phone in a party works out where the music should be by measuring its own offset from one server, the way NTP does. Send it the instant the song started and it can calculate, for itself, exactly where the playhead ought to be right now. Nothing ever has to say “play”.

That measurement is only ever as good as the round trip it rides on. A phone in London talking to a server in Mumbai is 200 milliseconds away, and more to the point the variation in those 200 milliseconds is large. Jitter is the one thing a sync algorithm cannot see through: it can average out noise given time, but noise is exactly what it is trying to measure against.

The obvious answer is a CDN, and it is the wrong one. A CDN caches content, and content was never the problem — an uploaded track is fetched by six people in one room, once, and deleted within hours. There is nothing to cache. What needs to be close is not the file. It is the clock.

Why this turned out to be easy

A party is a room. Everybody in one is in one place. That single fact removes almost all of the difficulty, because it means regions never have to talk to each other. There is no shared database, nothing to replicate, no consensus to reach, no clock to reconcile across an ocean. Each region is a complete, independent copy of the whole app.

Two pieces make it work.

Where a new party goes. The app asks every region for the time and keeps whichever answers soonest. Not a guess from an IP address — a measurement. Geo-IP gets a VPN wrong, gets an oddly routed mobile network wrong, and gets any country whose servers sit outside its borders wrong. Asking “which of you is nearest” gets all three right, and it costs one extra request because it is the same probe the clock runs anyway.

How a join finds it. The first character of the party code says which region holds it. 2K7QMX is India, 3ABCDE is the United States. Any phone, anywhere, reads the code and talks to the right server directly. No lookup, no directory service, no round trip to find out where to go.

The whole routing layer is about ninety lines. With one region configured it behaves exactly as a single server always did, which is the property that mattered most: nothing about it is speculative infrastructure waiting for a second deployment to justify itself.

3 September 2026

A countdown that a restart cannot reset

Guests get ten minutes without an account. Keeping that clock on the phone would mean it did not work at all.

Read the post

The feature is simple to describe. Somebody handed a link or a QR code can listen for ten minutes without signing up for anything. When the ten minutes are up, a free account carries them on.

The difficulty is entirely in making it survive being closed.

A countdown held in the phone resets the moment the app is killed, the tab is closed, the page is reloaded, or the browser's storage is cleared. Which is to say: it does not work. Anybody who wanted more time would find the way to get it within about four seconds, and would not even need to be trying.

Two keys, and the earlier one wins

So the clock lives on the server, and the phone is only ever told what is left. That much is obvious. The interesting part is what the clock is keyed on, because no single key is good enough.

A ticket in a cookie. Precise, survives closing the app, and is the normal case. Also trivially cleared.

A one-way scramble of the network address and the browser string. Coarse, but it is what catches the cleared cookie and the private window, because neither of those changes the router.

Neither works alone. A cookie is cleared; an address is shared by a whole household. Taking the earlier first-seen of the two means the honest case is measured precisely by the cookie, and the evasive case still runs into the fingerprint.

A household behind one router shares the ten minutes. That is a real cost and it is the right way round: it errs toward asking somebody to sign up, never toward handing out unlimited free time. Getting that direction backwards is how a limit becomes decorative.

Wall clock, not accumulated minutes

The countdown runs from first sight rather than adding up active time. It is simpler, it cannot be gamed by closing the app between songs, and it is what a countdown means to the person watching one. The number on screen is re-anchored to the server on every heartbeat and only ticks locally in between, so a phone whose own clock is wrong, or which was closed for an hour, cannot end up believing it has more time than it does.

2 September 2026

Why a song is downloaded once, not twenty times

A four minute song is 85 MB once decoded. An ordinary phone holds about two. The prefetch window wanted four.

Read the post

This one cost people real money, and the arithmetic is the whole story.

decodeAudioData turns a compressed file into raw 32-bit samples. Stereo at 44.1 kHz is roughly 21 MB of memory for every minute of music, whatever the file weighed on the way in. So a four minute song is about 85 MB of memory. The prefetch window wanted four songs ready at once, which is 339 MB. The memory budget on an ordinary phone is 160 MB.

It never fitted. It could not ever have fitted.

What happened next: the app downloaded a track, decoded it, and the memory manager immediately threw it away to get back under budget. A moment later the next state update arrived, noticed the track was missing, and downloaded it again. Round and round, every few seconds, for as long as the party lasted. Measured on a real browser with real files: 81 requests for 4 songs.

Keep the file, not just the audio

The fix is small and it is not the one people reach for first. The player used to hold only the decoded buffer, so when memory pressure took it back, the only way to get it again was the network.

Now the compressed file it was decoded from stays behind. A four minute song is about 5 MB as a file and 85 MB as audio, so holding the file costs a sixtieth of holding the audio — and coming back costs a re-decode instead of a download.

One detail worth knowing if you ever do this: decodeAudioData takes the buffer away from you. It is detached and unreadable afterwards. So it gets a copy and we keep ours, which is the only reason a second decode is possible without a second download.

And ask for what fits

The second half: the number of tracks decoded at once is now worked out from the memory budget and the length of the songs actually in the queue, rather than fixed at four. On an ordinary phone that comes to two — the one playing and the one after it. The rest of the window is still fetched, because the file is small, so when the window slides the next song is already there.

81 requests became 4. Three tests now fail loudly if anyone ever makes it 5.

30 August 2026

Nothing here ever says “play now”

Messages arrive late, and differently late on every phone. So we never send one.

Read the post

The naive way to sync music across phones is to send every device a message that says “start now”. It does not work, and it cannot be made to work, because that message does not arrive at the same time on every phone. It arrives 40 milliseconds later on one, 180 on another, and 12 on the one sitting next to the router. You have not synchronised anything. You have distributed your network's jitter directly into the audio.

So BeatBuddies never sends that message. There is no “play” command anywhere in it.

An instant, not an instruction

What the server sends is an anchor: this track was at this position, at this instant, on the shared clock. Every phone then works out for itself where the music should be right now:

position = anchorPosition + (serverNow − anchorAt) / 1000

That is the entire idea. A phone that receives the anchor 200 milliseconds late calculates a position 200 milliseconds further along, and starts there. It is not behind. It was never going to be behind, because nothing about the calculation depends on when the message arrived.

A slow phone is not a late phone. That sentence is the whole design.

The shared clock

All of which rests on every device agreeing what “now” is. Each one measures its own offset from the server the way NTP does, with a four-timestamp exchange, and keeps measuring so the estimate improves and follows drift.

It does not matter whether the server's clock is right. It only has to be the same for everyone. If the server were ten minutes wrong, every party would still be in perfect sync, because every device measures against the same wrong clock and the error cancels out exactly.

28 August 2026

Two clocks, and the drift between them

No two sound cards run at exactly the same speed. Over four minutes that difference becomes audible.

Read the post

Getting every phone to start together is the part people expect to be hard. It is not the hard part.

The hard part is that they do not stay together. Two phones told to play the same four minute song, started at precisely the same instant, will finish at measurably different times, because the crystal oscillator clocking each sound card is not running at exactly 44,100 Hz. It is running at 44,100 give or take a few parts per million, and those few parts per million are different in every device.

A drift of 30 parts per million is 7 milliseconds over four minutes. That is audible in a quiet room as a smearing of transients. 100 ppm is 24 milliseconds, which sounds like a slap echo.

Chasing is not enough

The obvious correction is to measure how far off you are and nudge the playback rate to catch up. It half works. The problem is that a pure chase is always reacting to an error that has already happened, and as soon as it catches up the drift starts opening the gap again. The correction saturates and never settles.

What is needed is for the phone to learn its own crystal, not just react to the symptom.

The integral term

So the controller keeps a running term — rateBias — that accumulates the persistent part of the error. Once it settles, the audio is running at the rate that cancels the crystal difference, and the proportional part of the correction has almost nothing left to do. The phone has effectively worked out that its sound card runs 34 parts per million fast, and is permanently compensating.

Two details that matter more than they look. Corrections are only applied every 250 milliseconds, because correcting continuously makes every device chase its own measurement noise and they drift apart in a new and more interesting way. And errors below a threshold are ignored entirely: a millisecond is inaudible, and correcting it only adds movement.

The number is on screen in Sync & settings, under “Sound card”. Watching it converge on a real phone was, personally, the most satisfying moment of building this.

24 August 2026

Writing a QR encoder by hand, and the bug that hid in it

The format information bits were being written in reverse. The code looked perfect and no scanner on earth would read it.

Read the post

The join code is shown as a QR, and the encoder that draws it is written from scratch rather than pulled from a library. There is one reason for that: the app has to work with no connection. The interface is inside the APK and behind a service worker precisely so a party in a basement still opens, and fetching a script off a CDN to draw the join code would undo that on the one screen that most needs it.

It does byte mode, error correction level M, versions 1 to 10. Level M rather than L because this gets scanned across a room, on a screen with fingerprints on it, held by somebody who has had a drink.

The bug

A finished QR carries fifteen bits of format information — the error correction level and which mask pattern was applied — written twice, in two different places, in two different orientations. I had the placement loop right and the bit order backwards.

The result is a code that looks completely correct. All the finder patterns are there, the timing patterns are right, the data modules are genuinely correct data. Hold it up to any scanner in the world and nothing happens, because the scanner reads the format bits first, gets nonsense, and stops.

You cannot see this by looking. That is the whole lesson.

Three ways of checking

What found it was refusing to trust the thing at all. Every codeword was compared against Python's qrcode library. Then every module of the finished matrix was compared against the same. Then each one was read back by OpenCV's scanner.

The first two checks are what caught it, because the third only tells you that something is wrong, not where. Eleven cases now pass all three, and three golden matrices are in the test suite so it cannot come back. The browser test goes further and screenshots the QR a real browser rendered, then decodes the screenshot.

If you are ever tempted to write one of these: the specification is readable and the maths is enjoyable, but budget most of your time for verification rather than implementation. The implementation is a weekend. Knowing it is right is the work.