Skip to content
morewebsPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

musync

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.


How the sync works

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.

Why crystal drift is not a problem

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.

Error budget

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

Layout

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_trap to abort on any allocation from the audio callback.

Building

Prerequisites

rustup target add aarch64-linux-android armv7-linux-androideabi

Set 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

Build and run

flutter pub get
flutter run --release      # release: the servo's timing is worth real optimisation

After changing anything in rust/src/api/:

flutter_rust_bridge_codegen generate

Cutting a release

.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.

Local patches to vendored files

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 calls project.exec(), removed in Gradle 9. Replaced with ProcessBuilder.
  • rust_builder/android/build.gradle — template ships compileSdkVersion 33 and minSdkVersion 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.

Tests

cd rust && cargo test --release

53 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.

Verifying sync for real

Simulation is not proof. To actually check the claim:

  1. Two Android devices on the same Wi-Fi. Host a room, add a track, join from the second device, press play.
  2. By ear: hold both phones together. You should hear one sound — no flanging, no echo.
  3. 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.
  4. 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.
  5. 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.

Known limitations

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages