the sounds you left behind, heard together.
You left. For coaching in Kota, for the hostel when the holidays ended, on the Sunday-night bus back to the city job. The sounds of home didn't come with you — but tonight, everyone who left hears the same track at the same instant. That's the whole idea: sync isn't a feature here, it's how far-away people sit in one coach together.
The journey passes through the places where India used to listen together — and those are the stations. The barber's radio deciding what the whole salon hummed during the ₹20 cut. The platform tea stall's speaker crackling the same Kumar Sanu song over every glass of cutting chai before your train. The truck or bus cab on NH-44, a two-seat concert hall doing 60 through the night. The music was never yours or mine — it was the place's, and everyone in the place heard the same beat at the same instant.
Ghar Se Door makes each of those a station anyone can open — named anything, themed any vibe: your Kota coaching train, your first-night hostel room, your Shillong bedroom-tapes crew, your "something nobody's thought of yet". Everyone tuned in hears the same track at the same position, actually synchronized — not a decorative "28 online" counter, but real clients scheduled onto one shared clock.
npm install
npm start # builds the client, then serves app + ws on http://localhost:4600
npm test # clock-sync unit tests (Vitest)
npm run dev # optional: Vite HMR client (run `npm run serve` alongside)Open http://localhost:4600 in two browser windows, join the same station in
both, and watch the position readouts tick in lockstep.
The server's clock is the only clock that matters. Every station stores one
timestamp — startedAtServerMs, the server-clock instant its current track
started — and broadcasts it to every member. Clients never trust their own
wall clock; they measure how wrong it is and correct for it.
NTP-style ping/pong sampling, run on every connect and every reconnect:
client server
│ ping {t0 = local clock} │
│ ───────────────────────────────────▶│
│ │ stamps serverNow
│ pong {t0, serverNow} │
│ ◀─────────────────────────────────── │
│ t1 = local clock │
│ │
│ rtt = t1 − t0 │
│ offset = serverNow + rtt/2 − t1 │ ×7 samples
estimateOffset() picks the minimum-RTT sample rather than averaging: the
fastest round trip spent the least time in network/event-loop queues, so its
"the server stamped this halfway through" assumption is the most honest.
On receiving station-state {trackId, startedAtServerMs, serverNow} a client
never calls a bare "play now". It computes where the station is and schedules
the Web Audio clock to land exactly there:
serverNow() = Date.now() + offset // local clock, corrected
elapsed = elapsedAt(startedAtServerMs, serverNow() + lead)
position = elapsed % trackDuration // tracks loop seamlessly
source.start(audioCtx.currentTime + lead, position) // scheduled, with offset
Same broadcast timestamp + same correction math on every client ⇒ every client lands on the same sample.
A client joining 43s into a track receives the station's original
startedAtServerMs, so the same formula naturally yields elapsed ≈ 43s and
playback starts mid-track — never from zero. lib/sync/clock.test.ts pins
this with a mid-track-join test asserting a non-zero elapsed position.
Drop the wifi and the socket dies; the client retries with exponential backoff
(0.5s → 8s cap). On reopening it re-measures the clock offset first (a
laptop that slept may have drifted), rejoins its station, receives the
untouched startedAtServerMs, and reschedules at the station's current
position. The station's timeline lives on the server and is never affected by
any client's connection state.
A hand-written, committed service worker (no build step generates it). Its
fetch handler has an explicit audio branch: any request under /audio/ or
/media/ (or with an audio extension) is answered from the Cache API
cache-first; misses are fetched once and cached. After one listen, stop the
server entirely — the music still plays.
Server state is one Map keyed by the slug of whatever name you typed:
"Bhai Ka Garage 🔧" → bhai-ka-garage. The three stations you see at boot
are seeded through the exact same createStation() call a user's station
goes through — nothing in the create/join path consults any fixed list. A
cutting-chai tapri, a Northeast indie bedroom-tapes crew, your "something
nobody's thought of yet": all of those are stations users make, not fixtures.
Every station has a shareable link at its own clean path (/s/hostel-room-204)
— send it to a friend and they land already tuned in, one tap to unmute. The
path carries the same slug the server keys the station by (lib/station-slug.ts
is the one definition both sides use), so no percent-encoded names in the
address bar.
There is only one view in this app: joining never navigates anywhere. The
wordmark becomes the station, the illustration cross-fades to that station's
scene, and the lobby collapses out of the way; the route changes underneath
with pushState, so back and forward walk between the lobby and the station
without a reload, and stepping back out of a station really leaves it. A cold
load of /s/<slug> is served the app shell (by the server, or by the service
worker when you are offline) and tunes in through the same post-sync join path.
Making one is its own room: the last line of the lineup, create your own
station, opens /new, a view with its own illustration and the form given
space to breathe. Back returns you to the lobby, same document throughout.
Each station carries a vibe that picks its visual skin — "one station, any
vibe", literally. Seeded stations can also carry an illustration of their very
own, keyed by the same slug as their URL; a station whose painting has not
been drawn yet quietly falls back to its vibe's scene. The skins are the journey itself: the window seat with
sodium streetlights through rain, the night bus with one strip light left on,
the train window at dusk as your town slides away. Every screen, lobby
included, wears its own hand-generated illustration (no two are alike),
full-bleed with no page scrim, destination-board display type and lowercase
mono for everything small (reservation-chart energy), film grain over the
lot. Imagery credited and md5-pinned in public/img/CREDITS.md.
The seeded stations are the stops on the way out of your hometown:
| seeded station | the stop | vibe/skin |
|---|---|---|
| Kota Express | the coaching train, leaving at dusk | moody · train window |
| NH-44 Night Run | the overnight bus back to the city | warm-night · night bus |
| Hostel Room 204 | first night away, rain on the window | warm · window seat |
The repo ships only CC0, self-synthesized instrumental loops
(public/audio/, provenance in public/audio/LICENSE.md) — no copyrighted
audio is in this repository or its history, ever.
Your personal music goes in the gitignored media/ directory: drop any
mp3/ogg/wav/m4a/flac in there and the server auto-discovers it on the next
refresh. Your files never enter git; they're your cassettes, plugged into the
shared deck.
Your music takes over. The moment media/ holds one playable file, the
committed loops leave the catalog entirely — every station, every dropdown,
your library only. Empty the directory and the loops come back, which is why a
fresh clone always has something to play. The rule is one function,
preferCassettes() in lib/catalog.ts.
Stations start somewhere random. Each seeded station rolls its own track independently at boot, so two can land on the same one when the catalog is small, and all three re-roll on every restart. In the create form, surprise me leads the track picker and is the default — pin a track there only when you want that exact one. The server treats any track id it does not recognise as a roll, so the sentinel needs no special case.
Stations keep moving. When a track finishes, the station moves to another
one at random — never the one that just played. The server decides and
broadcasts, because every listener has to land on the same next track at the
same instant; clients only report how long a track runs, which they learn by
decoding it. The move is timed to the end of the loop currently playing rather
than to startedAt + duration, so a station nobody has joined for an hour does
not yank the track out from under whoever just arrived — a late joiner still
lands mid-track and stays there. An empty station goes dormant instead of
shuffling to itself for ever, and picks up again when someone joins. A
one-track catalog simply loops, as before.
Decoded audio is capped at three tracks (src/track-cache.ts). decodeAudio- Data expands a file into float32 PCM, so a four-minute stereo track is about
92 MB in memory — fine for the short committed loops, ruinous for an evening
of full songs. The track on the deck is never evicted.
- Two windows, one station: join the same station in two browser windows;
the
positionreadout under the station name ticks the same value in both. The listeners line ("2 listening") is real membership. - Late join: start one window, wait ~20s, join from a second — it enters mid-track at the station's position, not at 0:00.
- Simulated drop: DevTools → Network → Offline (or kill the wifi) in one
window. Watch the footer go "disconnected, retrying…", flip back online,
and the client resyncs into the still-running station. The server log shows
distinct
[leave]/[join]lines; other listeners never notice a restart. - Two stations, two timelines: join different stations in two windows —
different tracks at independent positions (each station's
startedAtServerMsis its own). - Offline replay: load a station once, stop the server (
Ctrl-C), reload the page — the shell and the audio come out of the service worker's caches.
Plain TypeScript, no framework: a Vite-built vanilla client, a Node
ws server run with tsx, Vitest for the
clock math. The load-bearing sync code is ~150 lines across
lib/sync/clock.ts, src/player.ts, and the ping/pong handlers — on purpose.