Skip to content

fix(audio): clear audioSwitch synchronously in AudioSwitchHandler.stop()#967

Merged
davidliu merged 1 commit into
livekit:mainfrom
YashJainSC:fix/audioswitch-stale-switch-on-restart
Jun 17, 2026
Merged

fix(audio): clear audioSwitch synchronously in AudioSwitchHandler.stop()#967
davidliu merged 1 commit into
livekit:mainfrom
YashJainSC:fix/audioswitch-stale-switch-on-restart

Conversation

@YashJainSC

Copy link
Copy Markdown
Contributor

Summary

On Android 12+ (the CommDeviceAudioSwitch path), audio output can get stuck on the earpiece at very low volume after a disconnect() + connect() on a reused Room instance (e.g. switching between rooms without releasing/recreating the Room). The first connection routes to the speaker correctly; a subsequent reconnect intermittently does not.

Root cause

A race in AudioSwitchHandler between stop() and the next start():

  • stop() nulls audioSwitch inside the posted teardown runnable (on the handler thread) while tearing down handler/thread synchronously.
  • audioSwitch is a non-@Volatile field, and that write happens off the lock on the handler thread.
  • A fast subsequent start() (a different thread, under the @Synchronized lock) can therefore read a stale, already-stopped switch and skip re-creation via the if (audioSwitch == null) guard.

With no new switch created, activate() never re-runs, so the route that the prior deactivate() cleared via clearCommunicationDevice() (reverting the OS to the earpiece) is never re-asserted.

Instrumented logs from a reproduction (good connect, then a bad reconnect on the same Room):

# 1st connect — OK (ends on speaker)
start() called. handlerAlive=false, threadAlive=false, audioSwitchNull=true
start(): audioSwitch == null -> creating a new switch
deviceChange: available=[Speakerphone, Earpiece], selected=Speakerphone
switch started+activated (CommDeviceAudioSwitch). selected=Speakerphone

# reconnect on the SAME Room — BAD (stuck on earpiece)
stop() called. audioSwitchNull=false
stop(): posted audioSwitch.stop() + audioSwitch=null on handler thread   # field nulled here
start() called. handlerAlive=false, threadAlive=false, audioSwitchNull=false   # 456ms later, STILL reads non-null
-> SKIPPING re-create (reusing stale, stopped switch)   # no activate(), routing not re-asserted

The audioSwitch = null write is wall-clock before the start() read, yet start() observes non-null — a memory-visibility issue, compounded by the structural asymmetry that handler/thread are nulled synchronously while audioSwitch is not.

Fix

Clear audioSwitch synchronously under the lock in stop() (capturing the instance into a local so its stop() can still be posted to the handler thread, since AbstractAudioSwitch is not threadsafe), and mark the field @Volatile. This guarantees the next start() observes the teardown and re-creates the switch.

Verification

With the fix, every reconnect on the reused Room logs start(): audioSwitch == null -> creating a new switchactivate()selected=Speakerphone, and audio reliably routes to the speaker (no more SKIPPING re-create). Verified on-device (Android 15, Moto G45 5G) across repeated room switches.

🤖 Generated with Claude Code

Audio output could get stuck on the earpiece (very low volume) after a
disconnect() + connect() on a reused Room on Android 12+
(CommDeviceAudioSwitch).

stop() nulled `audioSwitch` inside the posted teardown runnable while
tearing down handler/thread synchronously. Because the field was not
volatile and the write happened off the lock on the handler thread, a
fast subsequent start() could read a stale (already-stopped) switch and
skip re-creation via the `if (audioSwitch == null)` guard. With no new
switch created, activate() never re-ran, so the routing cleared by the
prior deactivate()'s clearCommunicationDevice() was never re-asserted and
playout stayed on the earpiece until the next connect.

Clear `audioSwitch` synchronously under the lock and mark it @volatile so
start() reliably observes the teardown and re-creates the switch. The
switch's stop() is still posted to its handler thread, since
AbstractAudioSwitch is not threadsafe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d0cee0d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
client-sdk-android Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@davidliu

Copy link
Copy Markdown
Contributor

Thanks for the PR!

@davidliu davidliu merged commit b6bc06e into livekit:main Jun 17, 2026
5 checks passed
@davidliu davidliu mentioned this pull request Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants