One song, every phone, same instant — over Wi-Fi only.
One device hosts a room; others on the same network join it and play the same track at the same moment on their own speakers. There is no backend, no cloud, and no internet dependency: the host runs an embedded Rust server, peers find it over mDNS, and the audio file is transferred directly between devices.
The design target is inter-device drift well under 20 ms — close enough that two people standing next to each other hear one sound rather than an echo.
Nearly everything follows from one decision: the host does not send a "play now" command. It publishes a transport anchor.
struct TransportAnchor {
epoch: u64,
state: Idle | Prepared | Playing | Paused,
track_id: [u8; 32], // BLAKE3 of the file
host_time_ns: i64, // H0, host CLOCK_MONOTONIC
media_frame: i64, // P0, at a canonical 48 kHz
}It reads: at host time H0, the frame physically leaving the DAC is source
frame P0. Media position is then a pure function of host time:
m(t_host) = P0 + (t_host − H0) · rate / 1e9
Play, pause, seek, resync and late-join are all "publish a new anchor". A client
that receives an anchor whose H0 is three minutes in the past evaluates the
same formula and correctly starts three minutes into the track — there is no
special-case code path for a late joiner anywhere in the system. The render
loop is a servo, never a trigger.
Four layers make that anchor actionable on each device:
1. A shared clock. Every deadline is CLOCK_MONOTONIC nanoseconds, read from
Rust via clock_gettime (vDSO, ~25 ns, realtime-safe). That clock is not a
preference — it is the one AAudioStream_getTimestamp reports in, so any other
reference would need an extra conversion in the hottest part of the system.
2. An NTP-style exchange. Clients probe the host over a dedicated UDP socket
on a dedicated thread (never the TCP control channel — head-of-line blocking
injects asymmetric delay, and offset error is exactly half the path asymmetry).
32 probes at 25 ms on join, then 1 Hz. The 8 lowest-delay samples from a
64-sample window feed a weighted least-squares fit of θ(t) = θ0 + s·(t − t_ref).
Minimum-delay selection rather than a median because the Wi-Fi delay
distribution is a hard floor plus a long right tail, so the median is biased,
not merely noisy.
3. A frames↔time model. AAudio has no "start at time T" API, so musync does
not schedule the stream — it schedules the content. A 50 Hz thread samples
getTimestamp and fits T(F) = a + b·F, where a absorbs the entire output
latency and 1e9/b is the DAC's true measured rate (e.g. 48000.62 Hz). Every
callback then asks: when will the audio I am writing right now leave the
speaker?
4. A position servo. The target source frame is recomputed from first principles each callback, so no position state exists that could drift. A deliberately sluggish PI loop (τ ≈ 10 s, 0.3 ms deadband) walks the read cursor onto it via a continuous resample ratio — never by inserting or dropping frames, which would be an audible click 2.4 times a second at 50 ppm.
Two devices' DACs commonly differ by 30–50 ppm, which open-loop is 15 ms of
divergence over a five-minute track. But a PI controller with integral action has
zero steady-state error against a ramp: the integrator parks the ratio at
exactly 1 + drift and the only cost is a ~0.6 ms transient at acquisition. This
is verified in servo.rs, which simulates 5 minutes at 50 ppm and asserts peak
error under 2 ms.
| Source | Typical | Worst realistic |
|---|---|---|
| Clock offset (±δ/2, min-δ filtered) | ±0.6 ms | ±3 ms |
| Servo steady-state | ±0.3 ms | ±0.6 ms |
presentationTime reporting (wired/internal) |
±0.5 ms | ±2 ms |
| Frames↔time model residual | ±0.2 ms | ±0.5 ms |
| Inter-device (two devices, RSS) | ±1.3 ms | ±5.2 ms |
rust/src/
clock.rs monotonic time, WLS clock model, seqlock, suspend detection
proto.rs TransportAnchor, bincode framing, probe packets, join codes
sync/probe.rs UDP probe exchange, min-δ filtering
net/host.rs tokio TCP server, control relay, file source
net/client.rs join, fetch + verify, telemetry
discovery.rs mdns-sd advertise and browse
media.rs symphonia decode + windowed-sinc resample
audio/timing.rs T(F) = a + b·F least-squares fit
audio/servo.rs PI position servo
audio/engine.rs the realtime render path
audio/ffi.rs C ABI the native shims call
api/musync.rs flutter_rust_bridge surface
android/app/src/main/
cpp/musync_oboe.cpp Oboe stream lifecycle + 50 Hz timestamp poll
kotlin/.../MusyncAudio.kt JNI wrapper, output-route detection
kotlin/.../PlaybackService.kt foreground service, wake/Wi-Fi/multicast locks
ios/Runner/MusyncAudio.swift AVAudioEngine — written, NOT compiled (see below)
lib/ Flutter UI
Two rules the code holds to:
- Dart is never in the timing path. Flutter draws the UI. Dart GC pauses run 5–50 ms against a 20 ms budget, so nothing with a deadline crosses that boundary — the platform channel carries lifecycle only.
- The render path is hard-realtime. No allocation, no locks, no logging, no
panics. Cross-thread state arrives via seqlocks. Build with
--features rt_alloc_trapto abort on any allocation from the audio callback.
rustup target add aarch64-linux-android armv7-linux-androideabiSet ANDROID_HOME and ANDROID_NDK_HOME — Flutter finds the SDK through its own
config, but the Rust build scripts do not.
On Windows, enable Developer Mode. Flutter needs symlink support to build any project with plugins:
start ms-settings:developers
flutter pub get
flutter run --release # release: the servo's timing is worth real optimisationAfter changing anything in rust/src/api/:
flutter_rust_bridge_codegen generate.github/workflows/release.yml is manual-only. Actions → Build & Release →
Run workflow, enter a version like 0.1.0, press the button. It runs the Rust
tests and flutter analyze, builds a universal release APK, checks that all
three ABI slices actually contain the Rust core and the Oboe shim, and publishes
a GitHub Release (as a draft by default) with the APK and a SHA256SUMS.txt.
Optional toggles on the form: smaller per-architecture APKs, an .aab for the
Play Store, pre-release, and publish-immediately instead of draft.
Signing. Without secrets the release is signed with the Android debug key. It installs and runs, and for a sideloaded LAN app that is usually fine — but a later properly-signed build will not install over it. To sign for real, set four repository secrets:
| Secret | Value |
|---|---|
ANDROID_KEYSTORE_BASE64 |
base64 -w0 my-release.jks |
ANDROID_KEYSTORE_PASSWORD |
keystore password |
ANDROID_KEY_ALIAS |
key alias |
ANDROID_KEY_PASSWORD |
key password |
Locally the same signing config reads android/key.properties
(storeFile / storePassword / keyAlias / keyPassword); with neither
present it falls back to the debug key so flutter run --release still works.
The workflow pins Flutter 3.44.2 and NDK 28.2.13676358. Those two move together — Flutter pins that exact NDK — so change both or neither.
Two files that flutter_rust_bridge_codegen copies into the repo have been
edited. flutter_rust_bridge_codegen integrate would overwrite both, so if
you ever re-run it, re-apply these:
rust_builder/cargokit/gradle/plugin.gradle— Cargokit still callsproject.exec(), removed in Gradle 9. Replaced withProcessBuilder.rust_builder/android/build.gradle— template shipscompileSdkVersion 33andminSdkVersion 19; raised to 36 and 26. AndroidX transitive dependencies now require callers to compile against 34+, and AAudio needs 26.
Both are marked with a LOCAL PATCH (musync) comment.
There is deliberately no file_picker dependency. It applies its own Kotlin
Gradle Plugin, which conflicts with Flutter's built-in Kotlin under AGP 9, and
its newer releases collide with device_info_plus over win32. File selection
is ~40 lines of ACTION_OPEN_DOCUMENT in MainActivity.kt, which also hands
Rust a real filesystem path rather than a content:// URI.
cd rust && cargo test --release53 tests, all passing. The ones that carry real weight:
wls_recovers_offset_and_skew_through_queueing_noise— feeds a synthetic trace where a third of samples carry 40 ms of downlink-only buffering (the Wi-Fi power-save failure mode) and asserts the fit still lands within 1 ms.absorbs_50ppm_drift_over_a_five_minute_track— the headline drift claim.does_not_chase_clock_model_noise— a mistuned servo downstream of a noisy clock model warbles audibly; this is the failure least likely to be caught by ear during development.two_devices_with_differing_crystals_stay_within_budget— drives two engines with different DAC rates, different clock offsets and different output latencies off a shared true timeline through the real render code for 5 simulated minutes.recovers_true_dac_rate_and_latency,rejects_a_hal_reporting_nonsense_rates— the timing model must refuse to play rather than lock confidently to a lie.
Simulation is not proof. To actually check the claim:
- Two Android devices on the same Wi-Fi. Host a room, add a track, join from the second device, press play.
- By ear: hold both phones together. You should hear one sound — no flanging, no echo.
- Objectively: record both devices with one external mic and cross-correlate the channels. The lag in samples is the real number, and it is the only thing that substantiates the 20 ms claim. Do this before believing any of the above.
- Leave a 5+ minute track running and watch the per-listener sync error on the host screen. It should stay in the low single-digit milliseconds.
- Background both apps, screen off, ten minutes. Test on Samsung and Xiaomi specifically — aggressive OEM battery managers are the real adversary for the foreground service and wake locks.
iOS is written but never compiled. musync was developed on Windows, where no
iOS toolchain exists. ios/Runner/MusyncAudio.swift implements the same contract
as the Android shim and the Rust core is entirely platform-independent, but it
has never been built or run. Treat it as a starting point that needs a compile
and a two-device measurement on real hardware. The Info.plist keys
(UIBackgroundModes, NSLocalNetworkUsageDescription, NSBonjourServices) are
in place.
Bluetooth output is approximate. Over A2DP the HAL's presentationTime is
routinely wrong by 100–300 ms, and sometimes not even consistently. Nothing can
correct for that automatically, so musync detects the route, warns, and leaves
the manual delay trim to line it up by ear. Wired or built-in speakers are
sample-accurate; Bluetooth is not.
Wi-Fi power save is the biggest practical threat. With it active the radio
wakes only on ~100 ms beacons and buffers downlink packets at the AP — a one-way
delay, i.e. pure asymmetry, worth up to 50 ms of offset error. The foreground
service takes WIFI_MODE_FULL_LOW_LATENCY to disable it, and playback is gated
on min_delta < 15 ms so a bad network shows "syncing" rather than starting
everyone apart. 5 GHz is much better than 2.4 GHz.
Whole tracks are transferred before playback. Decode and resample happen once, offline, so the render callback stays cheap enough to hold a deadline. Big files mean a visible download step on join.
mDNS needs the multicast lock on Android and is blocked outright on many
guest networks. The five-digit join code is the fallback and always works within
a /16.