Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
63db514
Android routing improvements
MaxHeimbrock Jul 30, 2026
7dba708
Unifying platform audio usage in samples
MaxHeimbrock Jul 30, 2026
2ab1928
Some different gating
MaxHeimbrock Jul 30, 2026
bdd8ced
First iteration in new investigation
MaxHeimbrock Aug 13, 2026
7d460b9
Device change listener
MaxHeimbrock Aug 13, 2026
a0da2dc
Still trying but from bt it still goes to earpiece
MaxHeimbrock Aug 13, 2026
0b4c0c2
Watchdog checks devices on Android
MaxHeimbrock Aug 13, 2026
b12d75b
Fix iOS audio: app-owned manual audio session for stable playout
MaxHeimbrock Jul 9, 2026
0310db1
Restore cached iOS audio session on last PlatformAudio dispose
MaxHeimbrock Aug 13, 2026
8d78d1f
Recover iOS platform audio after backgrounding
MaxHeimbrock Aug 13, 2026
a2607ec
Remove fixed Unity 6 backgrounding known issue from README
MaxHeimbrock Aug 14, 2026
e5cecc6
Add public audio output routing API surface (PAR-019)
MaxHeimbrock Aug 14, 2026
bf9b348
Add Android route manager backend in C# (PAR-020)
MaxHeimbrock Aug 14, 2026
201701d
Merge remote-tracking branch 'origin/max/par-019-routing-api-surface'…
MaxHeimbrock Aug 14, 2026
7ad6cfe
Add iOS session states, speaker preference, and route events (PAR-021)
MaxHeimbrock Aug 14, 2026
4c6f774
Rebuild the audio unit after mid-call session mode changes
MaxHeimbrock Aug 14, 2026
b3431ab
Merge branch 'max/par-020-unity-android-route-manager' into max/par-0…
MaxHeimbrock Aug 24, 2026
2c3edda
Merge branch 'max/android-audio-routing' into max/par-011-samples-mig…
MaxHeimbrock Aug 24, 2026
4e885d4
Migrate the samples off C#-JNI routing onto the SDK routing API (PAR-…
MaxHeimbrock Aug 24, 2026
90eac90
Document the audio output routing API in the README
MaxHeimbrock Aug 24, 2026
e9cde4b
Adding forgotten meta file
MaxHeimbrock Aug 24, 2026
6f98d84
Gate the Android call audio session on SetSessionAudioEnabled (PAR-023)
MaxHeimbrock Aug 24, 2026
106413d
Drop the unverified Bluetooth profile claims from the session docs
MaxHeimbrock Aug 24, 2026
3a2145c
Recover Unity audio on device changes only, and document the SCO trad…
MaxHeimbrock Aug 24, 2026
82a652e
Stop filtering the Unity audio recovery on deviceWasChanged
MaxHeimbrock Aug 25, 2026
53e533d
Restore game audio from remembered state, not from a live snapshot
MaxHeimbrock Aug 25, 2026
54438d1
Stop resetting Unity's audio engine: it breaks the platform's call route
MaxHeimbrock Aug 25, 2026
fe80315
Let a Bluetooth route pin finish before re-issuing it
MaxHeimbrock Aug 25, 2026
c7317f5
Back off and warn when the platform will not apply the route pin
MaxHeimbrock Aug 25, 2026
c0b6916
Describe the Bluetooth SCO limitation by what was measured
MaxHeimbrock Aug 25, 2026
ccb67b9
Close three session-lifetime holes found in the PR review
MaxHeimbrock Aug 25, 2026
3afe3b5
Correct four doc claims the PR review caught out of sync with the code
MaxHeimbrock Aug 25, 2026
269bbdd
Scope the settle gate to Bluetooth, report the route the platform has
MaxHeimbrock Aug 25, 2026
2d4e685
Await the iOS microphone permission before opening the capture
MaxHeimbrock Aug 25, 2026
a1907b5
Retry a failed session enter/leave instead of giving up on it
MaxHeimbrock Aug 25, 2026
5eecb19
Acquire the Android call session lazily instead of in the constructor
MaxHeimbrock Aug 26, 2026
6a53c65
Harden the PlatformAudio lifecycle (PAR-025)
MaxHeimbrock Aug 26, 2026
c869e21
Add the create-dispose-create PlayMode test for the phase-A gate (PAR…
MaxHeimbrock Aug 26, 2026
638229b
Removed IsSpeakerOutputPreferred to slim the API
MaxHeimbrock Aug 27, 2026
fe19705
Removed leftover build.meta
MaxHeimbrock Aug 31, 2026
6a1aec8
Java class for device change listener instead of loop obsever
MaxHeimbrock Sep 7, 2026
1955c98
Poll is now one-shot timer
MaxHeimbrock Sep 7, 2026
80d8537
Renaming and folding the SelectOutput into the existing SetPlayoutDevice
MaxHeimbrock Sep 7, 2026
1dc3494
Report client-initiated disconnects through Room.Disconnected
MaxHeimbrock Sep 8, 2026
a1e16e6
Harden the disconnect path after self-review
MaxHeimbrock Sep 8, 2026
6bd52ec
Tear the Meet call down from OnDisconnected only
MaxHeimbrock Sep 8, 2026
c82ce76
Merge branch 'max/disconnected-on-local-disconnect' into max/par-022-…
MaxHeimbrock Sep 8, 2026
352afce
Let the call audio session follow Room connections instead of a publi…
MaxHeimbrock Sep 8, 2026
5996368
IsRecording added to the public interface of platform audio instead o…
MaxHeimbrock Sep 9, 2026
ed00164
Drop the Unity-audio recovery from the Meet controller
MaxHeimbrock Sep 9, 2026
00f5cf3
Drop IsRecording from the public PlatformAudio API and let the ADM ow…
MaxHeimbrock Sep 9, 2026
5b06ca9
Document that the mobile call route carries the mic and warn on no-op…
MaxHeimbrock Sep 9, 2026
28c4a32
Merge remote-tracking branch 'origin/main' into max/par-022-phase-a-v…
MaxHeimbrock Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ IEnumerator ConnectToRoom()
}
```

Subscribe to `room.Disconnected` (or `room.DisconnectedWithReason`) for your teardown: it is raised for server-side disconnects and, with `DisconnectReason.ClientInitiated`, for your own `room.Disconnect()` or `Dispose()` as well, so one handler covers both. Handlers run before the room's handles are released. `room.ConnectionStateChanged` reports the same transitions, and `room.IsConnected` is true from the moment `Connected` is raised. One gap to know: a `Disconnect()` while the connect is still pending is a no-op, so disconnect again once `Connect` has completed.

### Video

#### Publishing a texture (e.g Unity Camera)
Expand Down Expand Up @@ -330,8 +332,6 @@ void TrackSubscribed(IRemoteTrack track, RemoteTrackPublication publication, Rem
With Platform Audio, the audio input and output are managed by the native ADM of WebRTC. This unlocks echo cancellation, noise suppression, auto gain control and hardware processing if available.

There are some known issues with Platform Audio, that we are working on resolving:
- On iOS, disposing of Platform Audio object stops Unity audio output
- On iOS and Unity 6, backgrounding the app breaks Platform Audio
- On MacOS with bluetooth headset, unmuting can break audio output

#### Initialize Platform Audio
Expand All @@ -356,11 +356,6 @@ void InitializePlatformAudio()
foreach (var device in playout)
Debug.Log($" [{device.Index}] {device.Name}");

if (platformAudio.RecordingDeviceCount > 0)
platformAudio.SetRecordingDevice(0);
if (platformAudio.PlayoutDeviceCount > 0)
platformAudio.SetPlayoutDevice(0);

Debug.Log($"PlatformAudio ready. AEC={echoCancellation}, NS={noiseSuppression}, AGC={autoGainControl}, HW={preferHardwareProcessing}");
}
catch (System.Exception e)
Expand Down Expand Up @@ -417,6 +412,74 @@ IEnumerator PublishLocalMicrophonePlatform(PlatformAudio platformAudio, Room roo

Using Platform Audio, for audio output of subscribed remote audio tracks you don't need any Unity handling.

#### Audio Output Routing

On mobile, the OS decides where call audio plays (Bluetooth headset, wired headset, loudspeaker, earpiece). The route is duplex: the microphone follows whatever output route is active, so there is no separate microphone selection on mobile (`SetRecordingDevice` has no effect there and logs a warning). `PlatformAudio` exposes a routing policy on top of that:

```cs
// Automatic policy: route to the best available output kind, most preferred first.
// The default ranking is Bluetooth > WiredHeadset > Speaker > Earpiece.
platformAudio.PlayoutPreference = new[] { AudioDeviceKind.Bluetooth, AudioDeviceKind.WiredHeadset, AudioDeviceKind.Speaker };

// Prefer the earpiece over the loudspeaker: the same list with the two swapped.
// A speakerphone toggle is just switching between these two rankings.
platformAudio.PlayoutPreference = new[] { AudioDeviceKind.Bluetooth, AudioDeviceKind.WiredHeadset, AudioDeviceKind.Earpiece, AudioDeviceKind.Speaker };

// Sticky override on an explicit user choice: audio stays routed to the device until
// the override is cleared or the device disappears (then the automatic policy resumes).
var (recording, playout) = platformAudio.GetDevices();
platformAudio.SetPlayoutDevice(playout[0].Guid);
platformAudio.ClearPlayoutDeviceSelection();

// Observability: raised on the Unity main thread whenever the available devices or
// the active route change. AudioDevice.Kind and AudioDevice.IsSelected tell you what
// each entry is and which one is playing.
platformAudio.DevicesChanged += (playoutDevices, recordingDevices) => { /* refresh your device UI */ };
```

##### The call audio session

Routing is only asserted while a call is in progress, and the SDK decides that for you: `PlatformAudio` holds the platform's call audio session while at least one `Room` is connected and releases it when the last one disconnects. So the usual pattern — create `PlatformAudio` once at startup to keep a single ADM alive across calls — needs nothing else:

```cs
var platformAudio = new PlatformAudio(); // no call session yet

// ... a call starts:
yield return room.Connect(url, token, options); // session taken
yield return platformAudio.StartRecording();

// ... the call ends:
platformAudio.StopRecording();
room.Disconnect(); // session released
```

While released, the SDK holds no call audio session: on iOS WebRTC's voice-processing unit is off and the session sits in a music-friendly idle state, and on Android 12+ the SDK requests neither `MODE_IN_COMMUNICATION` nor the output route pin, so the platform's normal routing applies. Constructing `PlatformAudio` outside a call issues no audio-mode traffic at all, and device enumeration and `DevicesChanged` keep working on both platforms, so a device picker can be populated before the first call. A `PlatformAudio` created while a room is already connected takes the session immediately; a room that carries no audio at all should simply not have a `PlatformAudio` alive. The session is taken and released as part of the room's own connection-state transitions, before `Room.Connected` and `Room.Disconnected` reach your handlers — on a local `Disconnect()` and a server-side disconnect alike.


Unity's own audio engine is a separate layer that the SDK does not touch, and it needs a little care from an app that plays its own audio (music, SFX) alongside calls. When an output device is added or removed, Unity reinitializes its engine, which **stops every `AudioSource`** — and it raises `AudioSettings.OnAudioConfigurationChanged` only afterwards, so by the time the app is notified there is nothing left playing to inspect. What should still be audible therefore has to be remembered from before the change and restarted in that callback. On Android the callback's `deviceWasChanged` argument is `false` even for a real device change, so it cannot be used to filter these events. This is Unity's own behavior — it reproduces in a plain Unity scene without the SDK — so restarting the app's sources is the app's responsibility; the Agents sample's `PlatformAudioController` shows one way to do it.

**Known limitation — the platform's Bluetooth SCO state can get stuck.** Android brings a Bluetooth headset's *call* link up asynchronously, and its SCO state machine can be left in a pending state that never resolves. While it is, the platform accepts `setCommunicationDevice` but never applies it (`AS.BtHelper: requestScoState: failed to connect in state 1`, `preferredCommunicationDevice: null`), so a call's audio — and any media the app plays alongside it — stays on the loudspeaker for the whole call and returns to the headset when the call ends. It is platform state, not app state: it survives the app being restarted, and the SDK cannot clear it (the outstanding request belongs to another client in the process). The SDK logs a warning naming this and retries with backoff.

Two things are known to provoke or reveal it, device-verified on a Pixel 8a (Android 16):

- Unity's audio engine claims the call link itself through the deprecated `AudioManager.startBluetoothSco()` when it initializes with a headset already connected — about 3 s before this SDK creates its ADM, and not triggered by anything in the SDK or the samples. Present in 2022.3 and Unity 6 alike; neither version uses the Android 12 communication-device API, which is why the two collide.
- Once stuck, only the platform clears it: disconnecting and reconnecting the headset (which triggers the platform's own `resetBluetoothSco`), toggling Bluetooth, or restarting the phone. After that, routing works normally — the call link comes up in well under a second.

The reliable workaround is to connect the headset *after* the app has started, or to reconnect it once if a call has landed on the loudspeaker.

Do **not** call `AudioSettings.Reset` as part of that recovery on Android. Unity has already reopened its output by the time it notifies you, so a reset adds nothing — and reinitializing the engine makes Unity claim a Bluetooth headset's call link through the deprecated `AudioManager.startBluetoothSco()`, which evicts the `setCommunicationDevice` route pin the SDK holds and can leave the platform's SCO state machine unable to connect at all (`AS.BtHelper: requestScoState: failed to connect in state 1` on every subsequent attempt). Call audio and game audio then both stay on the loudspeaker for the rest of the session, no matter how often the route is re-pinned. Restarting the app's own `AudioSource`s is enough and stays out of the platform's way.

One consequence to design around on Android: while a call session is active on a classic (BR/EDR) Bluetooth headset, the platform suspends the headset's A2DP media link and routes *all* output — the app's own media included — over the headset's call link. Observed on a Pixel 8a (Android 16) with `adb shell dumpsys audio`: `STREAM_MUSIC` moves to `bt_sco_hs` while the call is active and back to `bt_a2dp` afterwards. Game audio therefore keeps playing during a call, but at the call link's quality, and it returns to full quality once the room disconnects and the SDK releases the session. This is a platform property of classic Bluetooth, not something the routing API can override.

Per-platform behavior:

- **Android 12+ (API 31)**: the full `PlayoutPreference` ranking applies — the SDK routes to the highest-ranked available kind and re-routes on device changes; kinds missing from the list are never auto-selected (when nothing ranked is available, the OS default route applies). `SetPlayoutDevice` pins a device from `GetDevices().Playout` (by `Guid`) as the communication device; the pin is dropped once that device disappears. Pinning selects the call route, not only the output: Android pairs the microphone with the communication device — a Bluetooth headset's own mic, the built-in mic when the speaker is pinned (even with a wired headset plugged in), the headset mic for the earpiece or a wired headset — and moves a running capture along, so `SetRecordingDevice` has no effect (a warning is logged). While no room is connected, `SetPlayoutDevice` only records the choice — it is applied when the next room connects, and until then `GetDevices`/`DevicesChanged` keep reporting the platform's own route. There is deliberately no pending flag for that deferral: a pre-call device picker should treat its own last `SetPlayoutDevice` call as the pending choice and confirm application via the `IsSelected` flip in `GetDevices`/`DevicesChanged` once a room is connected; a deferred choice whose device disappears first is dropped for good (same rule as an active pin), observable as the device leaving the playout list. `DevicesChanged` is raised on communication-device changes and on device add/remove (via `AudioDeviceCallback`, bridged through the `LiveKitAudioDeviceMonitor` Java source plugin shipped in the package); there is no polling. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while a room is connected: the SDK then holds `MODE_IN_COMMUNICATION` with the route pinned, and clears the pin and restores the mode it replaced when the last room disconnects, while enumeration and `DevicesChanged` stay live either way. Note: since Android 13 the OS only honors the app's communication-mode request — and with it the route pin — while the app has an active voice-communication capture, so keep the mic capture running for the whole call, even while muted with the track unpublished (see `PlatformAudioController` in the Meet sample); an active capture outside a connected room hands routing back to the platform, so start it once the room is connected and stop it when the room disconnects.
- **Older Android**: no routing backend — `PlayoutPreference` is stored and round-trips but has no routing effect, and `SetPlayoutDevice` and `SetRecordingDevice` have no effect (a warning is logged). `DevicesChanged` is never raised.
- **iOS**: external devices (Bluetooth, wired) always take priority over the built-in outputs, so the Speaker/Earpiece relative order is the only part of the ranking with an effect. It decides where audio goes when no external device is connected, is applied through the audio session mode (never by overriding the output port), and takes effect immediately, including mid-call. `SetPlayoutDevice` has no effect (a warning is logged) — the OS owns route selection on iOS; present the system route picker (`AVRoutePickerView`) instead. `SetRecordingDevice` has no effect either (a warning is logged): the OS pairs the microphone with the active route. `GetDevices().Playout` is the audio session's current output route (iOS does not enumerate every reachable device), and `DevicesChanged` is raised when that route changes.
- **Desktop (Windows/macOS/Linux)**: output is selected per device with `SetPlayoutDevice` and input independently with `SetRecordingDevice`; the `PlayoutPreference` ranking has no routing effect. `DevicesChanged` is never raised (no hot-plug events yet).

> **Upgrading from 2.0.x:** `SetPlayoutDevice` used to be a no-op on Android and iOS. On Android 12+ it now pins the device as a sticky override that shadows `PlayoutPreference` until `ClearPlayoutDeviceSelection` is called or the device disappears. Remove any "select playout device 0 at startup" call (the earlier sample did this): on Android 12+ it would pin whichever device the OS lists first for the whole session. Call `SetPlayoutDevice` only on an explicit user choice and let the ranking route otherwise.

### RPC

Perform your own predefined method calls from one participant to another.
Expand Down
8 changes: 8 additions & 0 deletions Runtime/Plugins/Android.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions Runtime/Plugins/Android/LiveKitAudioDeviceMonitor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Bridges android.media.AudioDeviceCallback to C#.
//
// AudioDeviceCallback is an abstract class and Unity's AndroidJavaProxy can only
// implement Java interfaces, so the subclass has to live in Java. This class does
// nothing but forward device add/remove notifications to the Listener interface,
// which AndroidRouteController implements on the C# side through an AndroidJavaProxy.
// Unity compiles this source as part of the Gradle build (Android plugin, source form).
package io.livekit.unity;

import android.media.AudioDeviceCallback;
import android.media.AudioDeviceInfo;
import android.media.AudioManager;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

public final class LiveKitAudioDeviceMonitor extends AudioDeviceCallback {
// Diagnostic logging for the on-device verification of this bridge: shows whether
// Android invokes the callback and whether the forward into C# returns.
private static final String TAG = "LiveKit";

/** Implemented on the C# side via AndroidJavaProxy. */
public interface Listener {
/**
* Invoked on the main looper whenever audio output devices were added to or
* removed from the system. The counts only include sinks (output devices); a
* change that touches inputs alone is not reported, because input routing on
* Android follows the communication device and never needs a re-evaluation.
*/
void onAudioDevicesChanged(int addedSinks, int removedSinks);
}

private final AudioManager audioManager;
private final Listener listener;
private boolean registered;

public LiveKitAudioDeviceMonitor(AudioManager audioManager, Listener listener) {
this.audioManager = audioManager;
this.listener = listener;
}

/**
* Starts receiving callbacks on the main looper. Android delivers one immediate
* onAudioDevicesAdded with the currently connected devices right after registering.
*/
public synchronized void register() {
if (registered) {
return;
}
audioManager.registerAudioDeviceCallback(this, new Handler(Looper.getMainLooper()));
registered = true;
Log.i(TAG, "AudioDeviceMonitor: registered; outputs currently enumerable: "
+ audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).length);
}

public synchronized void unregister() {
if (!registered) {
return;
}
audioManager.unregisterAudioDeviceCallback(this);
registered = false;
}

@Override
public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
int sinks = countSinks(addedDevices);
Log.i(TAG, "AudioDeviceMonitor: onAudioDevicesAdded total=" + length(addedDevices)
+ " sinks=" + sinks + " thread=" + Thread.currentThread().getName());
if (sinks > 0) {
listener.onAudioDevicesChanged(sinks, 0);
Log.i(TAG, "AudioDeviceMonitor: forwarded added=" + sinks + " to C#");
}
}

@Override
public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
int sinks = countSinks(removedDevices);
Log.i(TAG, "AudioDeviceMonitor: onAudioDevicesRemoved total=" + length(removedDevices)
+ " sinks=" + sinks + " thread=" + Thread.currentThread().getName());
if (sinks > 0) {
listener.onAudioDevicesChanged(0, sinks);
Log.i(TAG, "AudioDeviceMonitor: forwarded removed=" + sinks + " to C#");
}
}

private static int length(AudioDeviceInfo[] devices) {
return devices == null ? 0 : devices.length;
}

private static int countSinks(AudioDeviceInfo[] devices) {
if (devices == null) {
return 0;
}
int count = 0;
for (AudioDeviceInfo device : devices) {
if (device.isSink()) {
count++;
}
}
return count;
}
}
32 changes: 32 additions & 0 deletions Runtime/Plugins/Android/LiveKitAudioDeviceMonitor.java.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading