Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
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
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,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 Down Expand Up @@ -417,6 +415,72 @@ 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). `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.OutputPreference = new[] { AudioOutputKind.Bluetooth, AudioOutputKind.WiredHeadset, AudioOutputKind.Speaker };

// Convenience toggle for the built-in outputs: reorders Speaker/Earpiece inside
// OutputPreference (the list is the single source of truth, there is no separate state).
platformAudio.IsSpeakerOutputPreferred = false; // prefer the earpiece

// Sticky override: 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.SelectOutput(playout[0]);
platformAudio.ClearOutputOverride();

// 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. `SetSessionAudioEnabled` is that switch, and it starts out enabled, so an app that creates `PlatformAudio` once at startup — the usual pattern, to keep a single ADM alive across calls — should hand the session back until it is needed:

```cs
var platformAudio = new PlatformAudio();
platformAudio.SetSessionAudioEnabled(false); // no call yet

// ... a call starts:
platformAudio.SetSessionAudioEnabled(true);
yield return platformAudio.StartRecording();

// ... the call ends:
platformAudio.StopRecording();
platformAudio.SetSessionAudioEnabled(false);
```

While disabled, 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. On Android the session is also acquired lazily: constructing `PlatformAudio` issues no audio-mode traffic even though session audio starts enabled — the first action that needs the session while it is enabled takes it (enabling it explicitly, changing the output preference or selection, or starting capture) — so the disable-right-after-creation pattern above is completely silent at startup. Device enumeration and `DevicesChanged` keep working on both platforms, so a device picker can be populated before the first call. Leaving session audio enabled outside a call asserts the call session (on Android: from the first routing action on) for as long as the instance lives, which is rarely what an app wants: the OS treats the app as being in a call, and the route stays pinned to the call policy instead of following the platform.

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. The Meet sample's `PlatformAudioController` shows the whole pattern.

**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 when the session is disabled — one more reason to hold the session only for the duration of a call. This is a platform property of classic Bluetooth, not something the routing API can override.

Per-platform behavior:

- **Android 12+ (API 31)**: the full `OutputPreference` 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). `SelectOutput` pins a device from `GetDevices().Playout` as the communication device; the pin is dropped once that device disappears. While session audio is disabled, `SelectOutput` only records the choice — it is applied when the session is next enabled, 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 `SelectOutput` call as the pending choice and confirm application via the `IsSelected` flip in `GetDevices`/`DevicesChanged` once the session is enabled; 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; changes that fire no OS event are caught by a poll with roughly 1.5 s of latency. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while session audio is enabled: the session is first taken by the first trigger that needs it while enabled — an explicit enable, an output preference/selection change, or capture starting; never by construction alone — the SDK then holds `MODE_IN_COMMUNICATION` with the route pinned, and clears the pin and restores the mode it replaced on disable, 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 without an enabled session hands routing back to the platform, so pair the two at the call boundaries.
- **Older Android**: no routing backend — `OutputPreference` is stored and round-trips but has no routing effect, and `SelectOutput` throws `NotSupportedException`. `DevicesChanged` is never raised.
- **iOS**: external devices (Bluetooth, wired) always take priority over the built-in outputs, so the Speaker/Earpiece relative order — `IsSpeakerOutputPreferred` — 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. `SelectOutput` throws `NotSupportedException` — the OS owns route selection on iOS; present the system route picker (`AVRoutePickerView`) instead. `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 — `SelectOutput` selects the playout device like `SetPlayoutDevice`, and the `OutputPreference` ranking has no routing effect. `DevicesChanged` is never raised (no hot-plug events yet).

### RPC

Perform your own predefined method calls from one participant to another.
Expand Down
Loading
Loading