LocalJam

Listen together, in sync, over local Wi-Fi. One phone hosts and plays its own songs, and everyone nearby hears the same moment in their own headphones, from the app or a browser. No internet and no accounts. Built in Flutter on its own clock-sync protocol and native audio engines.

Play

About

LocalJam is for the moment a few people want to hear the same thing: a car, a kitchen, a trip, a room full of headphones. One person starts a jam and plays music from their own files. Everyone on the same Wi-Fi or hotspot hears the same song at the same moment, on their own device.

Friends join from Nearby, a QR code or a link. Someone without the app scans the QR code and listens in their browser. There is no server, no account and no internet involved. Nothing leaves the room.

It is on Google Play for Android, ships as a download for macOS, and builds from source for iOS, Linux and Windows.

What I built

  • A documented wire protocol for synchronized playback, with versioning rules so a newer peer can add features an older one safely ignores
  • A host that runs inside the phone: a WebSocket server for the room and a plain HTTP server for the songs, artwork and the browser listener page
  • Clock sync between devices, and playback that starts every phone at the same instant and keeps them together
  • A native audio plugin with sample-accurate scheduled start on every platform: AVAudioEngine on iOS and macOS, MediaCodec into AudioTrack on Android, a shared C++ core on miniaudio for desktop, and Web Audio in the browser
  • A browser guest, built in Flutter web and served by the host itself, so a room needs only one installed app
  • A shared queue with “added by”, room styles (Open, Jam, Listen-only), co-hosts, per-person permissions and kick
  • Background playback with lock-screen controls, jam history, and a sync nudge for Bluetooth headphones
  • A headless CLI that can stand in as a host or a guest, so most of it can be tested without a second phone

Architecture

LocalJam architectureSongs -> Jam host: add. Jam host -> Media server: files. Jam host <-> Guests: app or browser, each with its own clock and audio engine: WebSocket: clock sync, play P at time T. Media server -> Guests: app or browser, each with its own clock and audio engine: songs, fetched ahead; the listener pageaddfilesWebSocket: clock sync,play P at time Tsongs, fetched ahead;the listener pageHost phoneGuests: app or browser, each with its own clock and audio engineSongsfiles, music libraryJam hostsession clock, queueMedia serverplain HTTP

The host’s monotonic clock is the session clock. Guests learn their offset from it, fetch every song ahead of time, and wait for the host to say “position P at time T”. Each device then starts its own audio at that instant on its own clock. Nothing streams in real time.

Engineering decisions

  • Never start “now”. Starting playback now cannot be synchronized, so the host always schedules a start 750 ms in the future and every device converts that time to its own clock. A device that misses the deadline joins late at the right position. It never holds up the room.
  • A clock is a line, not a number. Two phones’ crystals run tens of parts per million apart, which is several milliseconds a minute. Guests fit the offset as a level plus a skew over the last five minutes, keep only pings near the recent round-trip floor, and space their first burst out so one delayed reply from Wi-Fi power saving can’t bias all of them at once.
  • Correct drift without anyone hearing it. Each device checks itself about once a second. Under 15 ms it does nothing. Between 15 and 50 ms it changes speed by up to half a percent for a short while. Only past 50 ms does it jump.
  • Bluetooth is the biggest error. Headphones add 100 to 300 ms of their own delay. Native guests use the latency the platform reports, everyone gets a manual nudge saved per output device, and the drift check accounts for the nudge so it doesn’t correct it away.
  • Plaintext on purpose. A self-signed certificate on a LAN address puts a full-page warning in front of every browser guest, and an HTTPS page couldn’t talk to the host anyway. The QR code’s 128-bit token is the invitation, and the host refuses anything from off the local network before reading it.
  • Every file is hostile. Guests can add songs, so the audio engine checks every header before it trusts it. A 1 KB file can claim 2^62 frames, and it once made playback queue four billion segments. Now every platform refuses the same files, so a song plays everywhere or nowhere.
  • One host, one writer. Co-hosts can control the room, but only the host writes its state. When the host leaves the jam ends, and a host going to the background says so first, so guests don’t wait on a timeout.

Stack

Flutter and Dart across Android, iOS, macOS, Linux, Windows and the web. A pure Dart core for the protocol and clock sync, dart:io HTTP and WebSocket servers, Bonsoir for mDNS discovery, Swift, Kotlin and C++ for the audio plugin, and Web Audio for the browser guest.