Skip to content

Latest commit

 

History

38 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

घर से दूर · Ghar Se Door

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.

Run it

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.

How the sync works

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.

1. Clock-offset estimation (lib/sync/clock.ts)

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.

2. Scheduled playback (src/player.ts)

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.

3. Late join

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.

4. Reconnect = resync, never restart

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.

5. Offline replay (public/sw.js)

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.

Stations are yours (no preset list)

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

Bring your own cassette

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.

Verifying the sync yourself

  • Two windows, one station: join the same station in two browser windows; the position readout 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 startedAtServerMs is 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.

Stack

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.

About

Ghar Se Door is a synchronized listening experience for people away from home. Create shared stations inspired by India’s everyday soundscapes, where everyone hears the same track at the same position, at the same time. Because distance feels smaller when everyone listens together.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages