More Projects
59 in totalMulti-Speaker Sync
2023
Three stabs at turning a room of phones into one speaker array. The Flutter attempt reaches for just_audio behind an audio_session that was never finished being wired. The native Android one starts playback when Bluetooth SCO audio connects — the one idea worth keeping — but opens an AudioTrack that nothing ever writes PCM into, so it runs silent. The third is a mic-to-speaker loopback in Compose. None got past a single device, and none of the three ever exchanges a timestamp with a peer, which is the actual problem.
Project Details
2023
Experiment · Mobile
A room full of phones is a room full of speakers that nobody uses together. The idea was to make them behave as one array: same track, same instant, across every device that joins. Three separate attempts sit on disk, and the gap between them says something about where the problem actually is.
Worth stating what "same instant" costs, because it is the thing that makes this hard and the thing all three attempts skipped. Two speakers a metre apart are about three milliseconds of sound travel from each other, and human ears start hearing a doubled source somewhere around ten to twenty. Phones do not share a clock, their audio stacks add tens of milliseconds of buffering that varies by device and by build, and Bluetooth adds its own variable transport delay on top. So a working version needs a shared time base, a measured per-device output latency, and a scheduled start expressed in that time base rather than a "play now" call. None of the three attempts below has any of that. Each one is a different way of discovering that the plumbing is not the project.
Attempt one: Flutter
multiple_speaker_connect reaches for the high-level path: just_audio for playback and
audio_session to negotiate with the OS. It configures the session with
AudioSessionConfiguration.music(), activates it before playing a bundled asset, and
deactivates on stop, with the disposal handled properly in dispose(). Two buttons, play
and stop.
It also does not compile. _audioSession = AudioSession assigns the class rather than
awaiting AudioSession.instance, and the line was never finished. This attempt was
abandoned mid-sentence, which is roughly how long it took to work out that just_audio
gives you no handle on playback timing at all — and playback timing was the entire point.
Two smaller things confirm how early it stopped. The play handler loads
assets/audio_file.mp3, but the assets block in pubspec.yaml is still the commented-out
template and there is no assets directory, so the asset could not have resolved even with the
session line fixed. And the Android manifest declares only the legacy BLUETOOTH and
BLUETOOTH_ADMIN permissions with no version ceiling and none of the Android 12 runtime
equivalents, which is the shape of a manifest nobody has yet run on a modern phone.
Attempt two: native Android
MultiSpeaker drops to Kotlin and the raw audio APIs, and it is a genuinely more serious
piece of code:
- Audio focus first. Requests
AUDIOFOCUS_GAINonSTREAM_MUSICand only proceeds if the system grants it. - Bluetooth SCO handshake. Calls
startBluetoothSco(), then registers aBroadcastReceiveronACTION_SCO_AUDIO_STATE_UPDATED. - Event-driven start. Playback begins only when
EXTRA_SCO_AUDIO_STATEreportsSCO_AUDIO_STATE_CONNECTED, rather than blindly assuming the route is up. - AudioTrack built by hand at 44.1 kHz,
CHANNEL_OUT_STEREO,ENCODING_PCM_16BIT, buffer sized fromgetMinBufferSize. - API-level branching: the
AudioTrack.Builderpath withUSAGE_MEDIAandCONTENT_TYPE_MUSICon API 23 and up, the legacyMODE_STREAMconstructor below it. - Clean teardown.
onDestroystops SCO, unregisters the receiver and abandons audio focus. - Toast and Logcat tracing at every state transition, tagged
VirtualSpeaker.
The event-driven start is the part worth keeping. Most sample code calls startBluetoothSco()
and then plays, which races the route coming up and produces audio out of the wrong speaker
for the first second. Waiting on the broadcast is correct, and it is the only piece of any of
these three that I would carry into a real version.
Its manifest, though, declares no permissions at all. Not MODIFY_AUDIO_SETTINGS, which
startBluetoothSco needs, and not the Bluetooth ones either. The project also still has
Compose switched on in build.gradle.kts with the whole Compose BOM pulled in, while the
activity is an AppCompatActivity inflating a RelativeLayout that contains one centred
TextView reading "Virtual Speaker Example". There is no play control and no audio file in
the project.
Attempt three: a loopback in Compose
MyApplication is a different idea again: not playing a file, but reading the microphone and
writing it straight back out. An AudioRecord on MediaRecorder.AudioSource.MIC and an
AudioTrack on STREAM_MUSIC are built inside a DisposableEffect, both mono at 44.1 kHz
and 16-bit PCM with a buffer sized from AudioRecord.getMinBufferSize, then a LaunchedEffect
runs a while (true) that reads a ShortArray and writes it to the track, with a
DisposableEffect teardown stopping and releasing both.
It is the only one of the three with a correct-looking manifest, declaring RECORD_AUDIO,
MODIFY_AUDIO_SETTINGS and the Bluetooth permissions. It is also the furthest from
compiling: bufferSize is never declared in the scope that uses it, LocalContext.current is
read inside an effect rather than in composition, the theme it imports belongs to an unrelated
package, and the activity class is named VirtualSpeakerActivity while the manifest points at
.MainActivity. The composable's body ends at a comment saying the UI goes here.
Why it stopped
Two problems, one solvable and one not, at least not this way. The solvable one:
audioTrack.play() is called but nothing ever writes PCM into the track, so it runs open
and silent. The other one is the transport. Bluetooth SCO is the mono voice-call path — it
exists for headsets, at voice sample rates, and it is the wrong pipe for stereo music no
matter how carefully the AudioTrack above it is configured.
Underneath both of those is the thing neither attempt touched: there is no clock synchronisation anywhere in either project. Getting several phones to start a buffer at the same millisecond needs a shared time base and per-device latency compensation, and that, not playback plumbing, is the actual project. Shelved once that was clear.
The honest summary is that all three are single-device prototypes of a multi-device problem. Nothing here discovers a peer, opens a socket, exchanges a timestamp or schedules anything; each one is one phone making noise on its own, and the interesting half was never started. If it were picked up again the first commit would not be audio code at all, it would be an NTP-style offset exchange over the local network and a rig to measure each handset's output latency, with playback wired in only once those two produced numbers.
Project Details
2023
Experiment · Mobile