From 63db51400c36f3c1a44a500b8e2924766fd9d84b Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:13:50 +0200 Subject: [PATCH 01/35] Android routing improvements --- .../Runtime/Agent/PlatformAudioController.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 91c9756f..a07b11e6 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -46,6 +46,11 @@ public IEnumerator Publish(Room room) Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); + // Must run AFTER StartRecording: opening the mic is what flips Android into + // voice-communication mode and reroutes playback to the earpiece, overriding + // anything set earlier. + ApplyAndroidCommunicationRoute(true); + // AudioProcessingOptions.Default enables AEC, noise suppression, auto gain control // and prefers hardware processing. _source = new PlatformAudioSource(_platformAudio, AudioProcessingOptions.Default); @@ -96,6 +101,120 @@ bool InitializePlatformAudio() } } +#if UNITY_ANDROID && !UNITY_EDITOR + // Preference order for the voice-communication output route: + // Bluetooth > wired headset > built-in loudspeaker. The earpiece (and anything + // unrecognized) is never picked explicitly — when nothing ranked is available we + // leave the OS default in place, which on a phone IS the earpiece, so it naturally + // comes last. Note: Bluetooth devices only show up as communication devices if they + // support a voice profile (HFP/LE Audio); A2DP-only speakers can't carry call audio + // on Android and fall through to the loudspeaker. + static int RouteRank(int deviceType) + { + switch (deviceType) + { + case 26: // AudioDeviceInfo.TYPE_BLE_HEADSET + case 27: // AudioDeviceInfo.TYPE_BLE_SPEAKER + case 7: // AudioDeviceInfo.TYPE_BLUETOOTH_SCO + case 23: // AudioDeviceInfo.TYPE_HEARING_AID + return 0; + case 3: // AudioDeviceInfo.TYPE_WIRED_HEADSET + case 4: // AudioDeviceInfo.TYPE_WIRED_HEADPHONES + case 22: // AudioDeviceInfo.TYPE_USB_HEADSET + return 1; + case 2: // AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + return 2; + default: + return int.MaxValue; + } + } +#endif + + // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a + // documented no-op there): once the mic opens, the audio session runs in + // voice-communication mode, whose default route is the earpiece. Pick the best route + // per RouteRank via AudioManager — setCommunicationDevice on Android 12+ (API 31), + // where setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. + // The route is chosen once per session (when the mic opens); devices (dis)connecting + // mid-conversation are picked up on the next location visit. + static void ApplyAndroidCommunicationRoute(bool enable) + { +#if UNITY_ANDROID && !UNITY_EDITOR + try + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + int sdkInt = version.GetStatic("SDK_INT"); + + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var audioManager = activity.Call("getSystemService", "audio"); + + if (sdkInt >= 31) + { + if (enable) + { + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + AndroidJavaObject best = null; + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) + { + var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) + { + best?.Dispose(); + best = device; + bestRank = rank; + } + else + { + device.Dispose(); + } + } + + if (best != null) + { + bool ok = audioManager.Call("setCommunicationDevice", best); + Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); + best.Dispose(); + } + else + { + Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); + } + } + else + { + audioManager.Call("clearCommunicationDevice"); + } + } + else + { + // Legacy path (pre-API-31). If a Bluetooth or wired output is attached, + // leave routing to the OS instead of hijacking it with the loudspeaker; + // proper legacy Bluetooth SCO management (startBluetoothSco) is out of + // scope for this demo. The AudioManager queries are deprecated but this + // branch only ever runs on old devices. + if (enable + && (audioManager.Call("isBluetoothA2dpOn") + || audioManager.Call("isBluetoothScoOn") + || audioManager.Call("isWiredHeadsetOn"))) + { + Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); + return; + } + audioManager.Call("setSpeakerphoneOn", enable); + Debug.Log($"[PlatformAudioController] setSpeakerphoneOn({enable})"); + } + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); + } +#endif + } + public void Dispose() { IsPublished = false; @@ -122,9 +241,13 @@ public void Dispose() _source?.Dispose(); _source = null; + // Undo the route override so the OS default applies again outside the call. + ApplyAndroidCommunicationRoute(false); + _platformAudio?.Dispose(); _platformAudio = null; _room = null; } } + From 7dba708957db741c4f0fc1348a0492cadb8c410f Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:30:47 +0200 Subject: [PATCH 02/35] Unifying platform audio usage in samples --- .../Runtime/Agent/LiveKitAgentSession.cs | 6 +- .../Runtime/Agent/PlatformAudioController.cs | 113 ++++--- Samples~/Meet/Assets/Runtime/MeetManager.cs | 113 ++----- .../Assets/Runtime/PlatformAudioController.cs | 278 ++++++++++++++++++ .../Runtime/PlatformAudioController.cs.meta | 11 + 5 files changed, 391 insertions(+), 130 deletions(-) create mode 100644 Samples~/Meet/Assets/Runtime/PlatformAudioController.cs create mode 100644 Samples~/Meet/Assets/Runtime/PlatformAudioController.cs.meta diff --git a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs index 22455827..a2337bc1 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs @@ -8,7 +8,9 @@ // up the microphone, transcription, and chat-bubble log. public class LiveKitAgentSession : MonoBehaviour { - [SerializeField] + const string MicTrackName = "player-mic"; + + [SerializeField] private ChatLog _chatLog; TokenSourceComponent _tokenSourceComponent; @@ -81,7 +83,7 @@ IEnumerator Connect() // Create the WebRTC ADM before connecting. The SDK only wires automatic speaker // playout for remote tracks to a PlatformAudio that already exists at connect time; // initializing it after Connect leaves remote (agent) audio silent. - _audio = new PlatformAudioController(); + _audio = new PlatformAudioController(MicTrackName, AudioProcessingOptions.Default); if (!_audio.Initialize()) { Debug.LogError("[LiveKitAgentSession] Failed to initialize platform audio; aborting."); diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index a07b11e6..401bf3e3 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -5,31 +5,41 @@ using UnityEngine; // Drives the duplex platform audio (WebRTC ADM): captures the default microphone with -// AEC/NS/AGC and publishes it as a LiveKit track, and selects the default playout device -// through which remote tracks are played back automatically. Owns every resource it creates -// and tears them down in dependency order on Dispose. +// the configured audio processing (AEC/NS/AGC) and publishes it as a LiveKit track, and +// selects the default playout device through which remote tracks are played back +// automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays +// alive; Dispose tears everything down in dependency order. public sealed class PlatformAudioController : IDisposable { - const string MicTrackName = "player-mic"; + readonly string _trackName; + readonly AudioProcessingOptions _audioOptions; PlatformAudio _platformAudio; PlatformAudioSource _source; LocalAudioTrack _track; Room _room; + public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } + public PlatformAudioController(string trackName, AudioProcessingOptions audioOptions) + { + _trackName = trackName; + _audioOptions = audioOptions; + } + // Creates the WebRTC ADM. This MUST run before Room.Connect so the SDK wires automatic - // speaker playout for remote tracks to this ADM — otherwise remote (agent) audio is never + // speaker playout for remote tracks to this ADM — otherwise remote audio is never // routed to an output and stays silent. Returns false if the ADM could not be created. public bool Initialize() { return InitializePlatformAudio(); } - // Starts recording and publishes the mic track into the room. Initialize() must have been - // called (before the room connected) first. On any failure it disposes whatever was - // constructed and leaves IsPublished false; the caller should tear the rest down. + // Starts recording and publishes the mic track into the room. Initialize() must have + // been called (before the room connected) first. On any failure it unpublishes whatever + // was constructed and leaves IsPublished false; the ADM stays alive so a later Publish + // can retry. public IEnumerator Publish(Room room) { _room = room; @@ -39,6 +49,8 @@ public IEnumerator Publish(Room room) Debug.LogError("[PlatformAudioController] Publish called before Initialize(); aborting."); yield break; } + if (IsPublished) + yield break; // Begin capturing from the default microphone. On macOS/iOS this turns on the // recording privacy indicator and triggers the OS permission prompt; on Android @@ -51,12 +63,10 @@ public IEnumerator Publish(Room room) // anything set earlier. ApplyAndroidCommunicationRoute(true); - // AudioProcessingOptions.Default enables AEC, noise suppression, auto gain control - // and prefers hardware processing. - _source = new PlatformAudioSource(_platformAudio, AudioProcessingOptions.Default); - _track = LocalAudioTrack.CreateAudioTrack(MicTrackName, _source, _room); + _source = new PlatformAudioSource(_platformAudio, _audioOptions); + _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); - Debug.Log($"[PlatformAudioController] Publishing mic track '{MicTrackName}'..."); + Debug.Log($"[PlatformAudioController] Publishing mic track '{_trackName}'..."); var options = new TrackPublishOptions { AudioEncoding = new AudioEncoding { MaxBitrate = 64000 }, @@ -67,12 +77,44 @@ public IEnumerator Publish(Room room) if (publish.IsError) { Debug.LogError("[PlatformAudioController] Failed to publish microphone track."); - Dispose(); + Unpublish(); yield break; } IsPublished = true; - Debug.Log("[PlatformAudioController] Microphone track published (AEC enabled)."); + Debug.Log($"[PlatformAudioController] Microphone track '{_trackName}' published."); + } + + // Tears down the mic capture and track but keeps the ADM alive: remote playout + // continues and a later Publish() reuses it (e.g. a mute/unmute toggle). + public void Unpublish() + { + IsPublished = false; + + if (_track != null && _room != null) + { + Debug.Log("[PlatformAudioController] Unpublishing microphone track."); + _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + } + _track = null; + + if (_platformAudio != null) + { + try + { + _platformAudio.StopRecording(); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + } + } + + _source?.Dispose(); + _source = null; + + // Undo the route override so the OS default applies again outside the capture session. + ApplyAndroidCommunicationRoute(false); } // Sets up PlatformAudio with the default recording/playout devices. @@ -85,6 +127,15 @@ bool InitializePlatformAudio() $"[PlatformAudioController] PlatformAudio initialized " + $"({_platformAudio.RecordingDeviceCount} mic(s), {_platformAudio.PlayoutDeviceCount} speaker(s))."); + var (recording, playout) = _platformAudio.GetDevices(); + Debug.Log("[PlatformAudioController] Recording devices:"); + foreach (var device in recording) + Debug.Log($" [{device.Index}] {device.Name}"); + + Debug.Log("[PlatformAudioController] Playout devices:"); + foreach (var device in playout) + Debug.Log($" [{device.Index}] {device.Name}"); + if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); if (_platformAudio.PlayoutDeviceCount > 0) @@ -135,8 +186,8 @@ static int RouteRank(int deviceType) // voice-communication mode, whose default route is the earpiece. Pick the best route // per RouteRank via AudioManager — setCommunicationDevice on Android 12+ (API 31), // where setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. - // The route is chosen once per session (when the mic opens); devices (dis)connecting - // mid-conversation are picked up on the next location visit. + // The route is chosen once per Publish() (when the mic opens); devices (dis)connecting + // mid-conversation are picked up on the next publish. static void ApplyAndroidCommunicationRoute(bool enable) { #if UNITY_ANDROID && !UNITY_EDITOR @@ -217,32 +268,7 @@ static void ApplyAndroidCommunicationRoute(bool enable) public void Dispose() { - IsPublished = false; - - if (_track != null && _room != null) - { - Debug.Log("[PlatformAudioController] Unpublishing microphone track."); - _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); - } - _track = null; - - if (_platformAudio != null) - { - try - { - _platformAudio.StopRecording(); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); - } - } - - _source?.Dispose(); - _source = null; - - // Undo the route override so the OS default applies again outside the call. - ApplyAndroidCommunicationRoute(false); + Unpublish(); _platformAudio?.Dispose(); _platformAudio = null; @@ -250,4 +276,3 @@ public void Dispose() _room = null; } } - diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 1dc27c71..e67d40b0 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -64,13 +64,12 @@ public class MeetManager : MonoBehaviour private RtcVideoSource _localRtcVideoSource; private RtcAudioSource _localRtcAudioSource; - private PlatformAudioSource _platformAudioSource; private LocalVideoTrack _localVideoTrack; private LocalAudioTrack _localAudioTrack; private bool _cameraActive; private bool _microphoneActive; - private PlatformAudio _platformAudio; + private PlatformAudioController _platformAudioController; #region Lifecycle @@ -94,34 +93,24 @@ private void Start() private void InitializePlatformAudio() { - try + var audioOptions = new AudioProcessingOptions { - _platformAudio = new PlatformAudio(); - Debug.Log($"PlatformAudio initialized: {_platformAudio.RecordingDeviceCount} mics, " + - $"{_platformAudio.PlayoutDeviceCount} speakers"); - - var (recording, playout) = _platformAudio.GetDevices(); - Debug.Log("Recording devices:"); - foreach (var device in recording) - Debug.Log($" [{device.Index}] {device.Name}"); - - Debug.Log("Playout devices:"); - 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); + EchoCancellation = echoCancellation, + NoiseSuppression = noiseSuppression, + AutoGainControl = autoGainControl, + PreferHardware = preferHardwareProcessing + }; - Debug.Log($"PlatformAudio ready. AEC={echoCancellation}, NS={noiseSuppression}, AGC={autoGainControl}, HW={preferHardwareProcessing}"); - } - catch (System.Exception e) + _platformAudioController = new PlatformAudioController(LocalAudioTrackName, audioOptions); + if (!_platformAudioController.Initialize()) { - Debug.LogError($"Failed to initialize PlatformAudio, falling back to Unity audio: {e.Message}"); + Debug.LogError("Failed to initialize PlatformAudio, falling back to Unity audio"); usePlatformAudio = false; - _platformAudio = null; + _platformAudioController = null; + return; } + + Debug.Log($"PlatformAudio ready. AEC={echoCancellation}, NS={noiseSuppression}, AGC={autoGainControl}, HW={preferHardwareProcessing}"); } private void OnApplicationPause(bool pause) @@ -150,8 +139,7 @@ private void OnDestroy() } CleanUpAllTracks(); _webCamTexture?.Stop(); - _platformAudioSource?.Dispose(); - _platformAudio?.Dispose(); + _platformAudioController?.Dispose(); _room?.Disconnect(); } @@ -360,7 +348,7 @@ private void AddRemoteAudioTrack(RemoteAudioTrack audioTrack) { var sid = audioTrack.Sid; - if (usePlatformAudio && _platformAudio != null) + if (usePlatformAudio && _platformAudioController != null) { // PlatformAudio mode: ADM handles speaker playback automatically. // No AudioStream / GameObject needed. @@ -549,7 +537,7 @@ private IEnumerator PublishLocalMicrophone() { if (_microphoneActive) yield break; - if (usePlatformAudio && _platformAudio != null) + if (usePlatformAudio && _platformAudioController != null) yield return PublishLocalMicrophonePlatform(); else yield return PublishLocalMicrophoneUnity(); @@ -562,45 +550,8 @@ private IEnumerator PublishLocalMicrophonePlatform() { Debug.Log("Publishing microphone using PlatformAudio (ADM)"); - // Start recording (in case it was stopped by a previous mute). - // This turns on the privacy indicator on macOS/iOS. On Android this also - // awaits the RECORD_AUDIO runtime permission dialog if not yet granted. - if (_platformAudio != null) - { - yield return _platformAudio.StartRecording(); - } - - var audioOptions = new AudioProcessingOptions - { - EchoCancellation = echoCancellation, - NoiseSuppression = noiseSuppression, - AutoGainControl = autoGainControl, - PreferHardware = preferHardwareProcessing - }; - - _platformAudioSource = new PlatformAudioSource(_platformAudio, audioOptions); - _localAudioTrack = LocalAudioTrack.CreateAudioTrack(LocalAudioTrackName, _platformAudioSource, _room); - - var options = new TrackPublishOptions - { - AudioEncoding = new AudioEncoding { MaxBitrate = 64000 }, - Source = TrackSource.SourceMicrophone - }; - - var publish = _room.LocalParticipant.PublishTrack(_localAudioTrack, options); - yield return publish; - - if (publish.IsError) - { - Debug.LogError("Failed to publish microphone track"); - _platformAudioSource?.Dispose(); - _platformAudioSource = null; - _localAudioTrack = null; - yield break; - } - - _microphoneActive = true; - Debug.Log("Microphone published via PlatformAudio (AEC enabled)"); + yield return _platformAudioController.Publish(_room); + _microphoneActive = _platformAudioController.IsPublished; } private IEnumerator PublishLocalMicrophoneUnity() @@ -643,19 +594,11 @@ private IEnumerator PublishLocalMicrophoneUnity() private void UnpublishLocalMicrophone() { - if (usePlatformAudio && _platformAudioSource != null) + if (usePlatformAudio && _platformAudioController != null) { - try - { - _platformAudio?.StopRecording(); - } - catch (System.Exception e) - { - Debug.LogWarning($"Failed to stop recording: {e.Message}"); - } - - _platformAudioSource.Dispose(); - _platformAudioSource = null; + // The controller owns the platform track: this stops recording and + // unpublishes while keeping the ADM alive for the next unmute. + _platformAudioController.Unpublish(); } else { @@ -670,10 +613,11 @@ private void UnpublishLocalMicrophone() } _audioObjects.Remove(LocalAudioTrackName); } + + _room.LocalParticipant.UnpublishTrack(_localAudioTrack, false); + _localAudioTrack = null; } - _room.LocalParticipant.UnpublishTrack(_localAudioTrack, false); - _localAudioTrack = null; if (_participantTiles.TryGetValue(_localId, out var tile)) tile.SetMicMuted(true); _microphoneActive = false; @@ -743,8 +687,9 @@ private void CleanUpAllTracks() DisposeSource(ref _localRtcAudioSource); DisposeSource(ref _localRtcVideoSource); - _platformAudioSource?.Dispose(); - _platformAudioSource = null; + // Keep the ADM itself alive so the next call can reuse it; only the mic + // capture and track go away here. + _platformAudioController?.Unpublish(); foreach (var obj in _audioObjects.Values) { diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs new file mode 100644 index 00000000..401bf3e3 --- /dev/null +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections; +using LiveKit; +using LiveKit.Proto; +using UnityEngine; + +// Drives the duplex platform audio (WebRTC ADM): captures the default microphone with +// the configured audio processing (AEC/NS/AGC) and publishes it as a LiveKit track, and +// selects the default playout device through which remote tracks are played back +// automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays +// alive; Dispose tears everything down in dependency order. +public sealed class PlatformAudioController : IDisposable +{ + readonly string _trackName; + readonly AudioProcessingOptions _audioOptions; + + PlatformAudio _platformAudio; + PlatformAudioSource _source; + LocalAudioTrack _track; + Room _room; + + public bool IsInitialized => _platformAudio != null; + public bool IsPublished { get; private set; } + + public PlatformAudioController(string trackName, AudioProcessingOptions audioOptions) + { + _trackName = trackName; + _audioOptions = audioOptions; + } + + // Creates the WebRTC ADM. This MUST run before Room.Connect so the SDK wires automatic + // speaker playout for remote tracks to this ADM — otherwise remote audio is never + // routed to an output and stays silent. Returns false if the ADM could not be created. + public bool Initialize() + { + return InitializePlatformAudio(); + } + + // Starts recording and publishes the mic track into the room. Initialize() must have + // been called (before the room connected) first. On any failure it unpublishes whatever + // was constructed and leaves IsPublished false; the ADM stays alive so a later Publish + // can retry. + public IEnumerator Publish(Room room) + { + _room = room; + + if (_platformAudio == null) + { + Debug.LogError("[PlatformAudioController] Publish called before Initialize(); aborting."); + yield break; + } + if (IsPublished) + yield break; + + // Begin capturing from the default microphone. On macOS/iOS this turns on the + // recording privacy indicator and triggers the OS permission prompt; on Android + // it awaits the RECORD_AUDIO runtime permission dialog. + Debug.Log("[PlatformAudioController] Starting platform recording."); + yield return _platformAudio.StartRecording(); + + // Must run AFTER StartRecording: opening the mic is what flips Android into + // voice-communication mode and reroutes playback to the earpiece, overriding + // anything set earlier. + ApplyAndroidCommunicationRoute(true); + + _source = new PlatformAudioSource(_platformAudio, _audioOptions); + _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); + + Debug.Log($"[PlatformAudioController] Publishing mic track '{_trackName}'..."); + var options = new TrackPublishOptions + { + AudioEncoding = new AudioEncoding { MaxBitrate = 64000 }, + Source = TrackSource.SourceMicrophone + }; + var publish = _room.LocalParticipant.PublishTrack(_track, options); + yield return publish; + if (publish.IsError) + { + Debug.LogError("[PlatformAudioController] Failed to publish microphone track."); + Unpublish(); + yield break; + } + + IsPublished = true; + Debug.Log($"[PlatformAudioController] Microphone track '{_trackName}' published."); + } + + // Tears down the mic capture and track but keeps the ADM alive: remote playout + // continues and a later Publish() reuses it (e.g. a mute/unmute toggle). + public void Unpublish() + { + IsPublished = false; + + if (_track != null && _room != null) + { + Debug.Log("[PlatformAudioController] Unpublishing microphone track."); + _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + } + _track = null; + + if (_platformAudio != null) + { + try + { + _platformAudio.StopRecording(); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + } + } + + _source?.Dispose(); + _source = null; + + // Undo the route override so the OS default applies again outside the capture session. + ApplyAndroidCommunicationRoute(false); + } + + // Sets up PlatformAudio with the default recording/playout devices. + bool InitializePlatformAudio() + { + try + { + _platformAudio = new PlatformAudio(); + Debug.Log( + $"[PlatformAudioController] PlatformAudio initialized " + + $"({_platformAudio.RecordingDeviceCount} mic(s), {_platformAudio.PlayoutDeviceCount} speaker(s))."); + + var (recording, playout) = _platformAudio.GetDevices(); + Debug.Log("[PlatformAudioController] Recording devices:"); + foreach (var device in recording) + Debug.Log($" [{device.Index}] {device.Name}"); + + Debug.Log("[PlatformAudioController] Playout devices:"); + 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); + + return true; + } + catch (Exception e) + { + Debug.LogError($"[PlatformAudioController] Failed to initialize PlatformAudio: {e.Message}"); + _platformAudio?.Dispose(); + _platformAudio = null; + return false; + } + } + +#if UNITY_ANDROID && !UNITY_EDITOR + // Preference order for the voice-communication output route: + // Bluetooth > wired headset > built-in loudspeaker. The earpiece (and anything + // unrecognized) is never picked explicitly — when nothing ranked is available we + // leave the OS default in place, which on a phone IS the earpiece, so it naturally + // comes last. Note: Bluetooth devices only show up as communication devices if they + // support a voice profile (HFP/LE Audio); A2DP-only speakers can't carry call audio + // on Android and fall through to the loudspeaker. + static int RouteRank(int deviceType) + { + switch (deviceType) + { + case 26: // AudioDeviceInfo.TYPE_BLE_HEADSET + case 27: // AudioDeviceInfo.TYPE_BLE_SPEAKER + case 7: // AudioDeviceInfo.TYPE_BLUETOOTH_SCO + case 23: // AudioDeviceInfo.TYPE_HEARING_AID + return 0; + case 3: // AudioDeviceInfo.TYPE_WIRED_HEADSET + case 4: // AudioDeviceInfo.TYPE_WIRED_HEADPHONES + case 22: // AudioDeviceInfo.TYPE_USB_HEADSET + return 1; + case 2: // AudioDeviceInfo.TYPE_BUILTIN_SPEAKER + return 2; + default: + return int.MaxValue; + } + } +#endif + + // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a + // documented no-op there): once the mic opens, the audio session runs in + // voice-communication mode, whose default route is the earpiece. Pick the best route + // per RouteRank via AudioManager — setCommunicationDevice on Android 12+ (API 31), + // where setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. + // The route is chosen once per Publish() (when the mic opens); devices (dis)connecting + // mid-conversation are picked up on the next publish. + static void ApplyAndroidCommunicationRoute(bool enable) + { +#if UNITY_ANDROID && !UNITY_EDITOR + try + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + int sdkInt = version.GetStatic("SDK_INT"); + + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var audioManager = activity.Call("getSystemService", "audio"); + + if (sdkInt >= 31) + { + if (enable) + { + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + AndroidJavaObject best = null; + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) + { + var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) + { + best?.Dispose(); + best = device; + bestRank = rank; + } + else + { + device.Dispose(); + } + } + + if (best != null) + { + bool ok = audioManager.Call("setCommunicationDevice", best); + Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); + best.Dispose(); + } + else + { + Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); + } + } + else + { + audioManager.Call("clearCommunicationDevice"); + } + } + else + { + // Legacy path (pre-API-31). If a Bluetooth or wired output is attached, + // leave routing to the OS instead of hijacking it with the loudspeaker; + // proper legacy Bluetooth SCO management (startBluetoothSco) is out of + // scope for this demo. The AudioManager queries are deprecated but this + // branch only ever runs on old devices. + if (enable + && (audioManager.Call("isBluetoothA2dpOn") + || audioManager.Call("isBluetoothScoOn") + || audioManager.Call("isWiredHeadsetOn"))) + { + Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); + return; + } + audioManager.Call("setSpeakerphoneOn", enable); + Debug.Log($"[PlatformAudioController] setSpeakerphoneOn({enable})"); + } + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); + } +#endif + } + + public void Dispose() + { + Unpublish(); + + _platformAudio?.Dispose(); + _platformAudio = null; + + _room = null; + } +} diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs.meta b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs.meta new file mode 100644 index 00000000..217b7816 --- /dev/null +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e60c87b3bbd504941ae86b78548a89d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 2ab1928aa37577c98965a60eb7edcf20601d0bd1 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:04 +0200 Subject: [PATCH 03/35] Some different gating --- .../Assets/Runtime/Agent/PlatformAudioController.cs | 12 ++++++------ .../Meet/Assets/Runtime/PlatformAudioController.cs | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 401bf3e3..869c33ac 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -58,10 +58,10 @@ public IEnumerator Publish(Room room) Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); - // Must run AFTER StartRecording: opening the mic is what flips Android into - // voice-communication mode and reroutes playback to the earpiece, overriding - // anything set earlier. + +#if UNITY_ANDROID && !UNITY_EDITOR ApplyAndroidCommunicationRoute(true); +#endif _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -113,8 +113,10 @@ public void Unpublish() _source?.Dispose(); _source = null; +#if UNITY_ANDROID && !UNITY_EDITOR // Undo the route override so the OS default applies again outside the capture session. ApplyAndroidCommunicationRoute(false); +#endif } // Sets up PlatformAudio with the default recording/playout devices. @@ -179,7 +181,6 @@ static int RouteRank(int deviceType) return int.MaxValue; } } -#endif // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a // documented no-op there): once the mic opens, the audio session runs in @@ -190,7 +191,6 @@ static int RouteRank(int deviceType) // mid-conversation are picked up on the next publish. static void ApplyAndroidCommunicationRoute(bool enable) { -#if UNITY_ANDROID && !UNITY_EDITOR try { using var version = new AndroidJavaClass("android.os.Build$VERSION"); @@ -263,8 +263,8 @@ static void ApplyAndroidCommunicationRoute(bool enable) { Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); } -#endif } +#endif public void Dispose() { diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 401bf3e3..869c33ac 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -58,10 +58,10 @@ public IEnumerator Publish(Room room) Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); - // Must run AFTER StartRecording: opening the mic is what flips Android into - // voice-communication mode and reroutes playback to the earpiece, overriding - // anything set earlier. + +#if UNITY_ANDROID && !UNITY_EDITOR ApplyAndroidCommunicationRoute(true); +#endif _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -113,8 +113,10 @@ public void Unpublish() _source?.Dispose(); _source = null; +#if UNITY_ANDROID && !UNITY_EDITOR // Undo the route override so the OS default applies again outside the capture session. ApplyAndroidCommunicationRoute(false); +#endif } // Sets up PlatformAudio with the default recording/playout devices. @@ -179,7 +181,6 @@ static int RouteRank(int deviceType) return int.MaxValue; } } -#endif // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a // documented no-op there): once the mic opens, the audio session runs in @@ -190,7 +191,6 @@ static int RouteRank(int deviceType) // mid-conversation are picked up on the next publish. static void ApplyAndroidCommunicationRoute(bool enable) { -#if UNITY_ANDROID && !UNITY_EDITOR try { using var version = new AndroidJavaClass("android.os.Build$VERSION"); @@ -263,8 +263,8 @@ static void ApplyAndroidCommunicationRoute(bool enable) { Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); } -#endif } +#endif public void Dispose() { From bdd8ced83121f3e52639841bf076f797516ee317 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:27:11 +0200 Subject: [PATCH 04/35] First iteration in new investigation --- .../Assets/Runtime/PlatformAudioController.cs | 134 +++++++++++------- 1 file changed, 85 insertions(+), 49 deletions(-) diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 869c33ac..82901c99 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -8,7 +8,9 @@ // the configured audio processing (AEC/NS/AGC) and publishes it as a LiveKit track, and // selects the default playout device through which remote tracks are played back // automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays -// alive; Dispose tears everything down in dependency order. +// alive; Dispose tears everything down in dependency order. On Android the controller +// also owns the output route (loudspeaker over earpiece) for its whole lifetime — see +// ApplyAndroidCommunicationRoute. public sealed class PlatformAudioController : IDisposable { readonly string _trackName; @@ -33,7 +35,15 @@ public PlatformAudioController(string trackName, AudioProcessingOptions audioOpt // routed to an output and stays silent. Returns false if the ADM could not be created. public bool Initialize() { - return InitializePlatformAudio(); + if (!InitializePlatformAudio()) + return false; + +#if UNITY_ANDROID && !UNITY_EDITOR + // Remote playout through the ADM starts at room connect regardless of whether + // the mic is ever published, so the route must be in place for the whole session. + ApplyAndroidCommunicationRoute(); +#endif + return true; } // Starts recording and publishes the mic track into the room. Initialize() must have @@ -58,10 +68,11 @@ public IEnumerator Publish(Room room) Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); - #if UNITY_ANDROID && !UNITY_EDITOR - ApplyAndroidCommunicationRoute(true); -#endif + // Re-apply the preferred route: the available devices may have changed since + // Initialize (headset plugged in or removed). + ApplyAndroidCommunicationRoute(); +#endif _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -113,10 +124,10 @@ public void Unpublish() _source?.Dispose(); _source = null; -#if UNITY_ANDROID && !UNITY_EDITOR - // Undo the route override so the OS default applies again outside the capture session. - ApplyAndroidCommunicationRoute(false); -#endif + // The Android route override is deliberately kept: the ADM continues playing + // remote audio while the mic is unpublished (listen-only / muted), and clearing + // the route here would drop that playout back onto the earpiece. Teardown + // happens in Dispose. } // Sets up PlatformAudio with the default recording/playout devices. @@ -183,13 +194,17 @@ static int RouteRank(int deviceType) } // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a - // documented no-op there): once the mic opens, the audio session runs in - // voice-communication mode, whose default route is the earpiece. Pick the best route - // per RouteRank via AudioManager — setCommunicationDevice on Android 12+ (API 31), - // where setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. - // The route is chosen once per Publish() (when the mic opens); devices (dis)connecting - // mid-conversation are picked up on the next publish. - static void ApplyAndroidCommunicationRoute(bool enable) + // documented no-op there): remote tracks play through a voice-communication stream, + // whose default route is the earpiece. Pick the best route per RouteRank via + // AudioManager — setCommunicationDevice on Android 12+ (API 31), where + // setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. + // The route is session-scoped: applied in Initialize, re-evaluated on each Publish + // (devices (dis)connecting while the mic stays muted are only picked up on the next + // publish), and cleared in Dispose. Some OEMs reportedly ignore + // setCommunicationDevice unless the app also enters MODE_IN_COMMUNICATION; that mode + // is deliberately not set here because it suspends A2DP playback and repurposes the + // volume keys. + static void ApplyAndroidCommunicationRoute() { try { @@ -202,42 +217,35 @@ static void ApplyAndroidCommunicationRoute(bool enable) if (sdkInt >= 31) { - if (enable) + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + AndroidJavaObject best = null; + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) { - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - AndroidJavaObject best = null; - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) + var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) { - var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - { - best?.Dispose(); - best = device; - bestRank = rank; - } - else - { - device.Dispose(); - } - } - - if (best != null) - { - bool ok = audioManager.Call("setCommunicationDevice", best); - Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); - best.Dispose(); + best?.Dispose(); + best = device; + bestRank = rank; } else { - Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); + device.Dispose(); } } + + if (best != null) + { + bool ok = audioManager.Call("setCommunicationDevice", best); + Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); + best.Dispose(); + } else { - audioManager.Call("clearCommunicationDevice"); + Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); } } else @@ -247,16 +255,15 @@ static void ApplyAndroidCommunicationRoute(bool enable) // proper legacy Bluetooth SCO management (startBluetoothSco) is out of // scope for this demo. The AudioManager queries are deprecated but this // branch only ever runs on old devices. - if (enable - && (audioManager.Call("isBluetoothA2dpOn") - || audioManager.Call("isBluetoothScoOn") - || audioManager.Call("isWiredHeadsetOn"))) + if (audioManager.Call("isBluetoothA2dpOn") + || audioManager.Call("isBluetoothScoOn") + || audioManager.Call("isWiredHeadsetOn")) { Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); return; } - audioManager.Call("setSpeakerphoneOn", enable); - Debug.Log($"[PlatformAudioController] setSpeakerphoneOn({enable})"); + audioManager.Call("setSpeakerphoneOn", true); + Debug.Log("[PlatformAudioController] setSpeakerphoneOn(true)"); } } catch (Exception e) @@ -264,6 +271,31 @@ static void ApplyAndroidCommunicationRoute(bool enable) Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); } } + + // Hands output routing back to the OS default. Only called from Dispose: the route + // is session-scoped on purpose (see Unpublish). + static void ClearAndroidCommunicationRoute() + { + try + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + int sdkInt = version.GetStatic("SDK_INT"); + + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var audioManager = activity.Call("getSystemService", "audio"); + + if (sdkInt >= 31) + audioManager.Call("clearCommunicationDevice"); + else + audioManager.Call("setSpeakerphoneOn", false); + Debug.Log("[PlatformAudioController] Restored default audio route."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to clear communication route: {e.Message}"); + } + } #endif public void Dispose() @@ -273,6 +305,10 @@ public void Dispose() _platformAudio?.Dispose(); _platformAudio = null; +#if UNITY_ANDROID && !UNITY_EDITOR + ClearAndroidCommunicationRoute(); +#endif + _room = null; } } From 7d460b9f4ec8af6bbfd4913ab5e31f84f232f608 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:42:48 +0200 Subject: [PATCH 05/35] Device change listener --- .../Assets/Runtime/PlatformAudioController.cs | 127 +++++++++++++++--- 1 file changed, 106 insertions(+), 21 deletions(-) diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 82901c99..795b93b3 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -21,6 +21,10 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; +#if UNITY_ANDROID && !UNITY_EDITOR + CommunicationDeviceListener _routeListener; +#endif + public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -42,6 +46,7 @@ public bool Initialize() // Remote playout through the ADM starts at room connect regardless of whether // the mic is ever published, so the route must be in place for the whole session. ApplyAndroidCommunicationRoute(); + RegisterAndroidRouteListener(); #endif return true; } @@ -69,8 +74,9 @@ public IEnumerator Publish(Room room) yield return _platformAudio.StartRecording(); #if UNITY_ANDROID && !UNITY_EDITOR - // Re-apply the preferred route: the available devices may have changed since - // Initialize (headset plugged in or removed). + // Re-apply the preferred route: a device added while a pin is active does not + // fire the change listener on all devices, so the next unmute is the fallback + // pickup point for it. ApplyAndroidCommunicationRoute(); #endif @@ -193,14 +199,91 @@ static int RouteRank(int deviceType) } } + static int AndroidSdkInt() + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + return version.GetStatic("SDK_INT"); + } + + // Caller owns the returned object (wrap it in `using var`). + static AndroidJavaObject GetAudioManager() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + return activity.Call("getSystemService", "audio"); + } + + // C#-side implementation of the Java callback interface. AndroidJavaProxy can only + // implement interfaces, which is why this listens for communication-device changes + // rather than subclassing android.media.AudioDeviceCallback (an abstract class). + sealed class CommunicationDeviceListener : AndroidJavaProxy + { + public CommunicationDeviceListener() + : base("android.media.AudioManager$OnCommunicationDeviceChangedListener") { } + + // Invoked by Android on the activity's main executor — a JVM-attached thread, + // but NOT the Unity main thread: keep the body restricted to JNI and Debug.Log. + public void onCommunicationDeviceChanged(AndroidJavaObject device) + { + int type = device != null ? device.Call("getType") : -1; + Debug.Log($"[PlatformAudioController] Communication device changed (type={type}); re-evaluating route."); + device?.Dispose(); + ApplyAndroidCommunicationRoute(); + } + } + + // Re-evaluates the route whenever the OS changes the communication device — most + // importantly when the active device disconnects and playout would otherwise fall + // back to the earpiece. Registered for the whole session (Initialize until Dispose). + void RegisterAndroidRouteListener() + { + try + { + if (AndroidSdkInt() < 31) + return; + + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var audioManager = activity.Call("getSystemService", "audio"); + using var executor = activity.Call("getMainExecutor"); + + _routeListener = new CommunicationDeviceListener(); + audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _routeListener); + Debug.Log("[PlatformAudioController] Registered communication device listener."); + } + catch (Exception e) + { + _routeListener = null; + Debug.LogWarning($"[PlatformAudioController] Failed to register device listener: {e.Message}"); + } + } + + void UnregisterAndroidRouteListener() + { + if (_routeListener == null) + return; + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("removeOnCommunicationDeviceChangedListener", _routeListener); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to unregister device listener: {e.Message}"); + } + _routeListener = null; + } + // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a // documented no-op there): remote tracks play through a voice-communication stream, // whose default route is the earpiece. Pick the best route per RouteRank via // AudioManager — setCommunicationDevice on Android 12+ (API 31), where // setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. // The route is session-scoped: applied in Initialize, re-evaluated on each Publish - // (devices (dis)connecting while the mic stays muted are only picked up on the next - // publish), and cleared in Dispose. Some OEMs reportedly ignore + // and on every OS communication-device change (see RegisterAndroidRouteListener), + // and cleared in Dispose. Re-pinning is skipped when the best-ranked device is + // already active — our own setCommunicationDevice fires the change listener, and + // the no-op check is what stops that feedback loop. Some OEMs reportedly ignore // setCommunicationDevice unless the app also enters MODE_IN_COMMUNICATION; that mode // is deliberately not set here because it suspends A2DP playback and repurposes the // volume keys. @@ -208,15 +291,13 @@ static void ApplyAndroidCommunicationRoute() { try { - using var version = new AndroidJavaClass("android.os.Build$VERSION"); - int sdkInt = version.GetStatic("SDK_INT"); - - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - using var audioManager = activity.Call("getSystemService", "audio"); + using var audioManager = GetAudioManager(); - if (sdkInt >= 31) + if (AndroidSdkInt() >= 31) { + using var current = audioManager.Call("getCommunicationDevice"); + int currentId = current != null ? current.Call("getId") : -1; + using var devices = audioManager.Call("getAvailableCommunicationDevices"); int count = devices.Call("size"); AndroidJavaObject best = null; @@ -239,8 +320,15 @@ static void ApplyAndroidCommunicationRoute() if (best != null) { - bool ok = audioManager.Call("setCommunicationDevice", best); - Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); + if (best.Call("getId") == currentId) + { + Debug.Log($"[PlatformAudioController] Best route (type={best.Call("getType")}) already active; skipping re-pin."); + } + else + { + bool ok = audioManager.Call("setCommunicationDevice", best); + Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); + } best.Dispose(); } else @@ -278,14 +366,8 @@ static void ClearAndroidCommunicationRoute() { try { - using var version = new AndroidJavaClass("android.os.Build$VERSION"); - int sdkInt = version.GetStatic("SDK_INT"); - - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - using var audioManager = activity.Call("getSystemService", "audio"); - - if (sdkInt >= 31) + using var audioManager = GetAudioManager(); + if (AndroidSdkInt() >= 31) audioManager.Call("clearCommunicationDevice"); else audioManager.Call("setSpeakerphoneOn", false); @@ -306,6 +388,9 @@ public void Dispose() _platformAudio = null; #if UNITY_ANDROID && !UNITY_EDITOR + // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, + // and a still-registered listener would immediately re-pin the loudspeaker. + UnregisterAndroidRouteListener(); ClearAndroidCommunicationRoute(); #endif From a0da2dc79b29a8e74d18a2345f5a87c697daee0b Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:08:54 +0200 Subject: [PATCH 06/35] Still trying but from bt it still goes to earpiece --- .../Plugins/Android/AndroidManifest.xml | 1 + .../Assets/Runtime/PlatformAudioController.cs | 132 ++++++++++++++---- 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/Samples~/Meet/Assets/Plugins/Android/AndroidManifest.xml b/Samples~/Meet/Assets/Plugins/Android/AndroidManifest.xml index 94ea9440..e5cca6ac 100644 --- a/Samples~/Meet/Assets/Plugins/Android/AndroidManifest.xml +++ b/Samples~/Meet/Assets/Plugins/Android/AndroidManifest.xml @@ -7,6 +7,7 @@ + _platformAudio != null; @@ -44,9 +48,9 @@ public bool Initialize() #if UNITY_ANDROID && !UNITY_EDITOR // Remote playout through the ADM starts at room connect regardless of whether - // the mic is ever published, so the route must be in place for the whole session. - ApplyAndroidCommunicationRoute(); - RegisterAndroidRouteListener(); + // the mic is ever published, so the audio session must be set up for the whole + // controller lifetime. + SetupAndroidCommunicationAudio(); #endif return true; } @@ -69,9 +73,14 @@ public IEnumerator Publish(Room room) // Begin capturing from the default microphone. On macOS/iOS this turns on the // recording privacy indicator and triggers the OS permission prompt; on Android - // it awaits the RECORD_AUDIO runtime permission dialog. - Debug.Log("[PlatformAudioController] Starting platform recording."); - yield return _platformAudio.StartRecording(); + // it awaits the RECORD_AUDIO runtime permission dialog. On Android the capture + // keeps running across mute cycles (see Unpublish), so skip the restart. + if (!_isRecording) + { + Debug.Log("[PlatformAudioController] Starting platform recording."); + yield return _platformAudio.StartRecording(); + _isRecording = true; + } #if UNITY_ANDROID && !UNITY_EDITOR // Re-apply the preferred route: a device added while a pin is active does not @@ -115,25 +124,44 @@ public void Unpublish() } _track = null; - if (_platformAudio != null) - { - try - { - _platformAudio.StopRecording(); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); - } - } +#if UNITY_ANDROID && !UNITY_EDITOR + // Keep the capture stream open while muted. Since Android 13, AudioService only + // honors this app's MODE_IN_COMMUNICATION request — and with it the + // communication-device pin — while the app has ACTIVE voice-communication + // capture or playback: with the recorder stopped, the mode-owner stack reports + // "Active: false", the mode drops back to MODE_NORMAL, and Telecom re-asserts + // the earpiece route every ~6 s. The track is unpublished and its source + // disposed below, so no audio reaches the room, but the OS mic-in-use indicator + // stays on while muted — same as other conferencing apps. Recording stops in + // Dispose. +#else + StopRecordingIfActive(); +#endif _source?.Dispose(); _source = null; - // The Android route override is deliberately kept: the ADM continues playing - // remote audio while the mic is unpublished (listen-only / muted), and clearing - // the route here would drop that playout back onto the earpiece. Teardown - // happens in Dispose. + // The Android route override is likewise deliberately kept: the ADM continues + // playing remote audio while the mic is unpublished (listen-only / muted), and + // clearing the route here would drop that playout back onto the earpiece. + // Teardown happens in Dispose. + } + + // Stops the microphone capture if it is running. On Android this only happens on + // Dispose — see the note in Unpublish. + void StopRecordingIfActive() + { + if (_platformAudio == null || !_isRecording) + return; + try + { + _platformAudio.StopRecording(); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + } + _isRecording = false; } // Sets up PlatformAudio with the default recording/playout devices. @@ -232,6 +260,52 @@ public void onCommunicationDeviceChanged(AndroidJavaObject device) } } + // Enters voice-communication audio mode, applies the preferred output route, and + // starts watching for route changes — all held until Dispose. Owning + // MODE_IN_COMMUNICATION is what makes the setCommunicationDevice pin authoritative: + // without it the platform periodically reasserts its own default route (observed on + // Pixel 8a: after a Bluetooth session ended, Telecom's CallAudioRouteController + // flipped playout back to the earpiece every ~6 s, endlessly fighting the re-pin). + // Side effects while the mode is held: hardware volume keys control the call stream, + // and Bluetooth audio runs over HFP/SCO (call quality) instead of A2DP — standard + // for call apps. + void SetupAndroidCommunicationAudio() + { + try + { + using var audioManager = GetAudioManager(); + _savedAudioMode = audioManager.Call("getMode"); + audioManager.Call("setMode", 3 /* AudioManager.MODE_IN_COMMUNICATION */); + Debug.Log($"[PlatformAudioController] Audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to enter communication mode: {e.Message}"); + } + + ApplyAndroidCommunicationRoute(); + RegisterAndroidRouteListener(); + } + + void TeardownAndroidCommunicationAudio() + { + // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, + // and a still-registered listener would immediately re-pin the loudspeaker. + UnregisterAndroidRouteListener(); + ClearAndroidCommunicationRoute(); + + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("setMode", _savedAudioMode); + Debug.Log($"[PlatformAudioController] Audio mode restored ({_savedAudioMode})."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to restore audio mode: {e.Message}"); + } + } + // Re-evaluates the route whenever the OS changes the communication device — most // importantly when the active device disconnects and playout would otherwise fall // back to the earpiece. Registered for the whole session (Initialize until Dispose). @@ -283,10 +357,8 @@ void UnregisterAndroidRouteListener() // and on every OS communication-device change (see RegisterAndroidRouteListener), // and cleared in Dispose. Re-pinning is skipped when the best-ranked device is // already active — our own setCommunicationDevice fires the change listener, and - // the no-op check is what stops that feedback loop. Some OEMs reportedly ignore - // setCommunicationDevice unless the app also enters MODE_IN_COMMUNICATION; that mode - // is deliberately not set here because it suspends A2DP playback and repurposes the - // volume keys. + // the no-op check is what stops that feedback loop. The pin only holds while the + // app owns MODE_IN_COMMUNICATION — see SetupAndroidCommunicationAudio. static void ApplyAndroidCommunicationRoute() { try @@ -383,15 +455,13 @@ static void ClearAndroidCommunicationRoute() public void Dispose() { Unpublish(); + StopRecordingIfActive(); _platformAudio?.Dispose(); _platformAudio = null; #if UNITY_ANDROID && !UNITY_EDITOR - // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, - // and a still-registered listener would immediately re-pin the loudspeaker. - UnregisterAndroidRouteListener(); - ClearAndroidCommunicationRoute(); + TeardownAndroidCommunicationAudio(); #endif _room = null; From 0b4c0c232d6eaee1aad8d6aa0943d9141b26a754 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:57:14 +0200 Subject: [PATCH 07/35] Watchdog checks devices on Android --- .../Plugins/Android/AndroidManifest.xml | 1 + .../Runtime/Agent/LiveKitAgentSession.cs | 7 + .../Runtime/Agent/PlatformAudioController.cs | 415 +++++++++++++++--- Samples~/Meet/Assets/Runtime/MeetManager.cs | 20 +- .../Assets/Runtime/PlatformAudioController.cs | 136 +++++- 5 files changed, 490 insertions(+), 89 deletions(-) diff --git a/Samples~/Agents/Assets/Plugins/Android/AndroidManifest.xml b/Samples~/Agents/Assets/Plugins/Android/AndroidManifest.xml index abc4bbc0..de1869eb 100644 --- a/Samples~/Agents/Assets/Plugins/Android/AndroidManifest.xml +++ b/Samples~/Agents/Assets/Plugins/Android/AndroidManifest.xml @@ -7,6 +7,7 @@ + _platformAudio != null; public bool IsPublished { get; private set; } @@ -33,7 +44,16 @@ public PlatformAudioController(string trackName, AudioProcessingOptions audioOpt // routed to an output and stays silent. Returns false if the ADM could not be created. public bool Initialize() { - return InitializePlatformAudio(); + if (!InitializePlatformAudio()) + return false; + +#if UNITY_ANDROID && !UNITY_EDITOR + // Remote playout through the ADM starts at room connect regardless of whether + // the mic is ever published, so the audio session must be set up for the whole + // controller lifetime. + SetupAndroidCommunicationAudio(); +#endif + return true; } // Starts recording and publishes the mic track into the room. Initialize() must have @@ -52,16 +72,17 @@ public IEnumerator Publish(Room room) if (IsPublished) yield break; - // Begin capturing from the default microphone. On macOS/iOS this turns on the - // recording privacy indicator and triggers the OS permission prompt; on Android - // it awaits the RECORD_AUDIO runtime permission dialog. - Debug.Log("[PlatformAudioController] Starting platform recording."); - yield return _platformAudio.StartRecording(); + // No-op when StartCapture already ran at call start (the normal case on + // Android) or when the capture was kept running across a mute cycle (see + // Unpublish). + yield return StartCapture(); - #if UNITY_ANDROID && !UNITY_EDITOR - ApplyAndroidCommunicationRoute(true); -#endif + // Re-assert the preferred route immediately: the route watchdog would pick up + // any missed device change within its poll interval, but unmuting is a natural + // point to remove that latency. + ApplyAndroidCommunicationRoute(); +#endif _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -85,6 +106,38 @@ public IEnumerator Publish(Room room) Debug.Log($"[PlatformAudioController] Microphone track '{_trackName}' published."); } + // Starts the microphone capture without publishing a track; Publish() reuses the + // running capture. On macOS/iOS this turns on the recording privacy indicator and + // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime + // permission dialog. On Android call this as soon as the call starts, even when + // joining muted: since Android 13 the app's MODE_IN_COMMUNICATION request — and + // with it the communication-device pin — is only honored while the app has ACTIVE + // voice-communication capture or playback, and the ADM's playout stream does not + // register as active, only the recorder does. Without a running capture the pin is + // un-owned: it happens to hold in the simple fresh-session case, but after a + // Bluetooth connect/disconnect episode the platform reasserts the earpiece and + // wins against the change listener's re-pin. + public IEnumerator StartCapture() + { + if (_platformAudio == null) + { + Debug.LogError("[PlatformAudioController] StartCapture called before Initialize(); aborting."); + yield break; + } + if (_isRecording) + yield break; + + Debug.Log("[PlatformAudioController] Starting platform recording."); + yield return _platformAudio.StartRecording(); + _isRecording = true; + +#if UNITY_ANDROID && !UNITY_EDITOR + // The pin only became authoritative once the capture went active — re-assert + // the preferred route in case the platform moved it while the mode was un-owned. + ApplyAndroidCommunicationRoute(); +#endif + } + // Tears down the mic capture and track but keeps the ADM alive: remote playout // continues and a later Publish() reuses it (e.g. a mute/unmute toggle). public void Unpublish() @@ -98,25 +151,46 @@ public void Unpublish() } _track = null; - if (_platformAudio != null) - { - try - { - _platformAudio.StopRecording(); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); - } - } +#if UNITY_ANDROID && !UNITY_EDITOR + // Keep the capture stream open while muted. Since Android 13, AudioService only + // honors this app's MODE_IN_COMMUNICATION request — and with it the + // communication-device pin — while the app has ACTIVE voice-communication + // capture or playback: with the recorder stopped, the mode-owner stack reports + // "Active: false", the mode drops back to MODE_NORMAL, and Telecom re-asserts + // the earpiece route every ~6 s. The track is unpublished and its source + // disposed below, so no audio reaches the room, but the OS mic-in-use indicator + // stays on while muted — same as other conferencing apps. Recording stops in + // StopCapture (call end) or Dispose. +#else + StopCapture(); +#endif _source?.Dispose(); _source = null; -#if UNITY_ANDROID && !UNITY_EDITOR - // Undo the route override so the OS default applies again outside the capture session. - ApplyAndroidCommunicationRoute(false); -#endif + // The Android route override is likewise deliberately kept: the ADM continues + // playing remote audio while the mic is unpublished (listen-only / muted), and + // clearing the route here would drop that playout back onto the earpiece. + // Teardown happens in Dispose. + } + + // Stops the microphone capture if it is running. Only call this once the call has + // ended (after Unpublish): on Android, stopping the capture while still in a call + // hands routing authority back to the platform — see StartCapture. The next + // StartCapture (or Publish) restarts it. + public void StopCapture() + { + if (_platformAudio == null || !_isRecording) + return; + try + { + _platformAudio.StopRecording(); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + } + _isRecording = false; } // Sets up PlatformAudio with the default recording/playout devices. @@ -182,62 +256,248 @@ static int RouteRank(int deviceType) } } - // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a - // documented no-op there): once the mic opens, the audio session runs in - // voice-communication mode, whose default route is the earpiece. Pick the best route - // per RouteRank via AudioManager — setCommunicationDevice on Android 12+ (API 31), - // where setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. - // The route is chosen once per Publish() (when the mic opens); devices (dis)connecting - // mid-conversation are picked up on the next publish. - static void ApplyAndroidCommunicationRoute(bool enable) + static int AndroidSdkInt() + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + return version.GetStatic("SDK_INT"); + } + + // Caller owns the returned object (wrap it in `using var`). + static AndroidJavaObject GetAudioManager() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + return activity.Call("getSystemService", "audio"); + } + + // C#-side implementation of the Java callback interface. AndroidJavaProxy can only + // implement interfaces, which is why this listens for communication-device changes + // rather than subclassing android.media.AudioDeviceCallback (an abstract class). + sealed class CommunicationDeviceListener : AndroidJavaProxy + { + public CommunicationDeviceListener() + : base("android.media.AudioManager$OnCommunicationDeviceChangedListener") { } + + // Invoked by Android on the activity's main executor — a JVM-attached thread, + // but NOT the Unity main thread: keep the body restricted to JNI and Debug.Log. + public void onCommunicationDeviceChanged(AndroidJavaObject device) + { + int type = device != null ? device.Call("getType") : -1; + Debug.Log($"[PlatformAudioController] Communication device changed (type={type}); re-evaluating route."); + device?.Dispose(); + ApplyAndroidCommunicationRoute(); + } + } + + // Enters voice-communication audio mode, applies the preferred output route, and + // starts watching for route changes — all held until Dispose. Owning + // MODE_IN_COMMUNICATION is what makes the setCommunicationDevice pin authoritative: + // without it the platform periodically reasserts its own default route (observed on + // Pixel 8a: after a Bluetooth session ended, Telecom's CallAudioRouteController + // flipped playout back to the earpiece every ~6 s, endlessly fighting the re-pin). + // Side effects while the mode is held: hardware volume keys control the call stream, + // and Bluetooth audio runs over HFP/SCO (call quality) instead of A2DP — standard + // for call apps. + void SetupAndroidCommunicationAudio() + { + try + { + using var audioManager = GetAudioManager(); + _savedAudioMode = audioManager.Call("getMode"); + audioManager.Call("setMode", 3 /* AudioManager.MODE_IN_COMMUNICATION */); + Debug.Log($"[PlatformAudioController] Audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to enter communication mode: {e.Message}"); + } + + ApplyAndroidCommunicationRoute(); + RegisterAndroidRouteListener(); + } + + void TeardownAndroidCommunicationAudio() + { + // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, + // and a still-registered listener would immediately re-pin the loudspeaker. + UnregisterAndroidRouteListener(); + ClearAndroidCommunicationRoute(); + + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("setMode", _savedAudioMode); + Debug.Log($"[PlatformAudioController] Audio mode restored ({_savedAudioMode})."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to restore audio mode: {e.Message}"); + } + } + + // Re-evaluates the route whenever the OS changes the communication device — most + // importantly when the active device disconnects and playout would otherwise fall + // back to the earpiece. Registered for the whole session (Initialize until Dispose). + void RegisterAndroidRouteListener() { try { - using var version = new AndroidJavaClass("android.os.Build$VERSION"); - int sdkInt = version.GetStatic("SDK_INT"); + if (AndroidSdkInt() < 31) + return; using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); using var activity = unityPlayer.GetStatic("currentActivity"); using var audioManager = activity.Call("getSystemService", "audio"); + using var executor = activity.Call("getMainExecutor"); + + _routeListener = new CommunicationDeviceListener(); + audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _routeListener); + Debug.Log("[PlatformAudioController] Registered communication device listener."); + } + catch (Exception e) + { + _routeListener = null; + Debug.LogWarning($"[PlatformAudioController] Failed to register device listener: {e.Message}"); + } + } + + void UnregisterAndroidRouteListener() + { + if (_routeListener == null) + return; + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("removeOnCommunicationDeviceChangedListener", _routeListener); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to unregister device listener: {e.Message}"); + } + _routeListener = null; + } + + // Poll fallback for route changes that fire no communication-device event, run via + // StartCoroutine for the controller's whole lifetime. Device-verified gap on + // Pixel 8a (Android 16): when the Bluetooth headset powers off mid-call, SCO drops + // first and the communication device falls back to the earpiece while the headset + // is still in getAvailableCommunicationDevices — the listener's re-evaluation at + // that point still ranks the (dying) headset best. The headset leaves the device + // list up to ~10 s later WITHOUT another communication-device change (the device + // stays "earpiece"), so the listener never fires again and playout is stuck on the + // earpiece. Only a device-list diff catches that transition, and + // AudioDeviceCallback is an abstract class that AndroidJavaProxy cannot implement, + // hence polling. Also covers devices ADDED while a pin is active, which equally + // fires no event. + public IEnumerator AndroidRouteWatchdog() + { + var interval = new WaitForSeconds(1.5f); + while (IsInitialized) + { + if (AndroidRouteNeedsReapply()) + { + Debug.Log("[PlatformAudioController] Route watchdog detected divergence; re-evaluating."); + ApplyAndroidCommunicationRoute(); + } + yield return interval; + } + } + + // True when a strictly better-ranked communication device is available than the + // one currently active — the pinned device vanished and the OS fell back to the + // earpiece, or a better device appeared without an event. Rank (not id) comparison + // on purpose: a headset can expose several same-rank entries (BLE + SCO) and which + // of those the OS activates is its call, not a divergence to correct. Kept + // separate from ApplyAndroidCommunicationRoute so the quiescent poll stays two + // JNI queries with no logging. + static bool AndroidRouteNeedsReapply() + { + try + { + if (AndroidSdkInt() < 31) + return false; + + using var audioManager = GetAudioManager(); + using var current = audioManager.Call("getCommunicationDevice"); + int currentRank = current != null ? RouteRank(current.Call("getType")) : int.MaxValue; + + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) + { + using var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) + bestRank = rank; + } + return bestRank < currentRank; + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Route watchdog check failed: {e.Message}"); + return false; + } + } + + // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a + // documented no-op there): remote tracks play through a voice-communication stream, + // whose default route is the earpiece. Pick the best route per RouteRank via + // AudioManager — setCommunicationDevice on Android 12+ (API 31), where + // setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. + // The route is session-scoped: applied in Initialize, re-evaluated on each Publish + // and on every OS communication-device change (see RegisterAndroidRouteListener), + // and cleared in Dispose. Re-pinning is skipped when the best-ranked device is + // already active — our own setCommunicationDevice fires the change listener, and + // the no-op check is what stops that feedback loop. The pin only holds while the + // app owns MODE_IN_COMMUNICATION — see SetupAndroidCommunicationAudio. + static void ApplyAndroidCommunicationRoute() + { + try + { + using var audioManager = GetAudioManager(); - if (sdkInt >= 31) + if (AndroidSdkInt() >= 31) { - if (enable) + using var current = audioManager.Call("getCommunicationDevice"); + int currentId = current != null ? current.Call("getId") : -1; + + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + AndroidJavaObject best = null; + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) { - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - AndroidJavaObject best = null; - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) + var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) { - var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - { - best?.Dispose(); - best = device; - bestRank = rank; - } - else - { - device.Dispose(); - } + best?.Dispose(); + best = device; + bestRank = rank; } + else + { + device.Dispose(); + } + } - if (best != null) + if (best != null) + { + if (best.Call("getId") == currentId) { - bool ok = audioManager.Call("setCommunicationDevice", best); - Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); - best.Dispose(); + Debug.Log($"[PlatformAudioController] Best route (type={best.Call("getType")}) already active; skipping re-pin."); } else { - Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); + bool ok = audioManager.Call("setCommunicationDevice", best); + Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); } + best.Dispose(); } else { - audioManager.Call("clearCommunicationDevice"); + Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); } } else @@ -247,16 +507,15 @@ static void ApplyAndroidCommunicationRoute(bool enable) // proper legacy Bluetooth SCO management (startBluetoothSco) is out of // scope for this demo. The AudioManager queries are deprecated but this // branch only ever runs on old devices. - if (enable - && (audioManager.Call("isBluetoothA2dpOn") - || audioManager.Call("isBluetoothScoOn") - || audioManager.Call("isWiredHeadsetOn"))) + if (audioManager.Call("isBluetoothA2dpOn") + || audioManager.Call("isBluetoothScoOn") + || audioManager.Call("isWiredHeadsetOn")) { Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); return; } - audioManager.Call("setSpeakerphoneOn", enable); - Debug.Log($"[PlatformAudioController] setSpeakerphoneOn({enable})"); + audioManager.Call("setSpeakerphoneOn", true); + Debug.Log("[PlatformAudioController] setSpeakerphoneOn(true)"); } } catch (Exception e) @@ -264,15 +523,39 @@ static void ApplyAndroidCommunicationRoute(bool enable) Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); } } + + // Hands output routing back to the OS default. Only called from Dispose: the route + // is session-scoped on purpose (see Unpublish). + static void ClearAndroidCommunicationRoute() + { + try + { + using var audioManager = GetAudioManager(); + if (AndroidSdkInt() >= 31) + audioManager.Call("clearCommunicationDevice"); + else + audioManager.Call("setSpeakerphoneOn", false); + Debug.Log("[PlatformAudioController] Restored default audio route."); + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Failed to clear communication route: {e.Message}"); + } + } #endif public void Dispose() { Unpublish(); + StopCapture(); _platformAudio?.Dispose(); _platformAudio = null; +#if UNITY_ANDROID && !UNITY_EDITOR + TeardownAndroidCommunicationAudio(); +#endif + _room = null; } } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index e67d40b0..306922e1 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -111,6 +111,13 @@ private void InitializePlatformAudio() } Debug.Log($"PlatformAudio ready. AEC={echoCancellation}, NS={noiseSuppression}, AGC={autoGainControl}, HW={preferHardwareProcessing}"); + +#if UNITY_ANDROID && !UNITY_EDITOR + // Poll fallback for routing changes that fire no communication-device event + // (e.g. a Bluetooth headset leaving the device list after its SCO link already + // dropped) — see PlatformAudioController.AndroidRouteWatchdog. + StartCoroutine(_platformAudioController.AndroidRouteWatchdog()); +#endif } private void OnApplicationPause(bool pause) @@ -237,6 +244,15 @@ private IEnumerator ConnectToRoom() _localId = _room.LocalParticipant.Identity; buttonBar.SetConnected(true); +#if UNITY_ANDROID && !UNITY_EDITOR + // Keep the mic capture running for the whole call, even while muted: without + // an active capture Android treats the communication-mode request as inactive + // and the speaker route pin is not honored after a Bluetooth episode — see + // PlatformAudioController.StartCapture. Publishing (unmuting) reuses the capture. + if (usePlatformAudio && _platformAudioController != null) + StartCoroutine(_platformAudioController.StartCapture()); +#endif + EnsureParticipantTile(_localId); foreach (var remote in _room.RemoteParticipants.Values) EnsureParticipantTile(remote.Identity); @@ -688,8 +704,10 @@ private void CleanUpAllTracks() DisposeSource(ref _localRtcVideoSource); // Keep the ADM itself alive so the next call can reuse it; only the mic - // capture and track go away here. + // capture and track go away here (ConnectToRoom restarts the capture on the + // next call). _platformAudioController?.Unpublish(); + _platformAudioController?.StopCapture(); foreach (var obj in _audioObjects.Values) { diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index c0829420..5bd99cce 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -10,9 +10,10 @@ // automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays // alive; Dispose tears everything down in dependency order. On Android the controller // also owns the voice-communication audio session (mode + output route, loudspeaker -// over earpiece) for its whole lifetime, and the mic capture stays open across mute -// cycles to keep that session active — see SetupAndroidCommunicationAudio and -// Unpublish. +// over earpiece) for its whole lifetime; an active mic capture is what makes that +// session authoritative, so start it with StartCapture when the call begins (even +// when joining muted) — it then stays open across mute cycles until StopCapture when +// the call ends. See StartCapture, SetupAndroidCommunicationAudio and Unpublish. public sealed class PlatformAudioController : IDisposable { readonly string _trackName; @@ -71,21 +72,15 @@ public IEnumerator Publish(Room room) if (IsPublished) yield break; - // Begin capturing from the default microphone. On macOS/iOS this turns on the - // recording privacy indicator and triggers the OS permission prompt; on Android - // it awaits the RECORD_AUDIO runtime permission dialog. On Android the capture - // keeps running across mute cycles (see Unpublish), so skip the restart. - if (!_isRecording) - { - Debug.Log("[PlatformAudioController] Starting platform recording."); - yield return _platformAudio.StartRecording(); - _isRecording = true; - } + // No-op when StartCapture already ran at call start (the normal case on + // Android) or when the capture was kept running across a mute cycle (see + // Unpublish). + yield return StartCapture(); #if UNITY_ANDROID && !UNITY_EDITOR - // Re-apply the preferred route: a device added while a pin is active does not - // fire the change listener on all devices, so the next unmute is the fallback - // pickup point for it. + // Re-assert the preferred route immediately: the route watchdog would pick up + // any missed device change within its poll interval, but unmuting is a natural + // point to remove that latency. ApplyAndroidCommunicationRoute(); #endif @@ -111,6 +106,38 @@ public IEnumerator Publish(Room room) Debug.Log($"[PlatformAudioController] Microphone track '{_trackName}' published."); } + // Starts the microphone capture without publishing a track; Publish() reuses the + // running capture. On macOS/iOS this turns on the recording privacy indicator and + // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime + // permission dialog. On Android call this as soon as the call starts, even when + // joining muted: since Android 13 the app's MODE_IN_COMMUNICATION request — and + // with it the communication-device pin — is only honored while the app has ACTIVE + // voice-communication capture or playback, and the ADM's playout stream does not + // register as active, only the recorder does. Without a running capture the pin is + // un-owned: it happens to hold in the simple fresh-session case, but after a + // Bluetooth connect/disconnect episode the platform reasserts the earpiece and + // wins against the change listener's re-pin. + public IEnumerator StartCapture() + { + if (_platformAudio == null) + { + Debug.LogError("[PlatformAudioController] StartCapture called before Initialize(); aborting."); + yield break; + } + if (_isRecording) + yield break; + + Debug.Log("[PlatformAudioController] Starting platform recording."); + yield return _platformAudio.StartRecording(); + _isRecording = true; + +#if UNITY_ANDROID && !UNITY_EDITOR + // The pin only became authoritative once the capture went active — re-assert + // the preferred route in case the platform moved it while the mode was un-owned. + ApplyAndroidCommunicationRoute(); +#endif + } + // Tears down the mic capture and track but keeps the ADM alive: remote playout // continues and a later Publish() reuses it (e.g. a mute/unmute toggle). public void Unpublish() @@ -133,9 +160,9 @@ public void Unpublish() // the earpiece route every ~6 s. The track is unpublished and its source // disposed below, so no audio reaches the room, but the OS mic-in-use indicator // stays on while muted — same as other conferencing apps. Recording stops in - // Dispose. + // StopCapture (call end) or Dispose. #else - StopRecordingIfActive(); + StopCapture(); #endif _source?.Dispose(); @@ -147,9 +174,11 @@ public void Unpublish() // Teardown happens in Dispose. } - // Stops the microphone capture if it is running. On Android this only happens on - // Dispose — see the note in Unpublish. - void StopRecordingIfActive() + // Stops the microphone capture if it is running. Only call this once the call has + // ended (after Unpublish): on Android, stopping the capture while still in a call + // hands routing authority back to the platform — see StartCapture. The next + // StartCapture (or Publish) restarts it. + public void StopCapture() { if (_platformAudio == null || !_isRecording) return; @@ -348,6 +377,69 @@ void UnregisterAndroidRouteListener() _routeListener = null; } + // Poll fallback for route changes that fire no communication-device event, run via + // StartCoroutine for the controller's whole lifetime. Device-verified gap on + // Pixel 8a (Android 16): when the Bluetooth headset powers off mid-call, SCO drops + // first and the communication device falls back to the earpiece while the headset + // is still in getAvailableCommunicationDevices — the listener's re-evaluation at + // that point still ranks the (dying) headset best. The headset leaves the device + // list up to ~10 s later WITHOUT another communication-device change (the device + // stays "earpiece"), so the listener never fires again and playout is stuck on the + // earpiece. Only a device-list diff catches that transition, and + // AudioDeviceCallback is an abstract class that AndroidJavaProxy cannot implement, + // hence polling. Also covers devices ADDED while a pin is active, which equally + // fires no event. + public IEnumerator AndroidRouteWatchdog() + { + var interval = new WaitForSeconds(1.5f); + while (IsInitialized) + { + if (AndroidRouteNeedsReapply()) + { + Debug.Log("[PlatformAudioController] Route watchdog detected divergence; re-evaluating."); + ApplyAndroidCommunicationRoute(); + } + yield return interval; + } + } + + // True when a strictly better-ranked communication device is available than the + // one currently active — the pinned device vanished and the OS fell back to the + // earpiece, or a better device appeared without an event. Rank (not id) comparison + // on purpose: a headset can expose several same-rank entries (BLE + SCO) and which + // of those the OS activates is its call, not a divergence to correct. Kept + // separate from ApplyAndroidCommunicationRoute so the quiescent poll stays two + // JNI queries with no logging. + static bool AndroidRouteNeedsReapply() + { + try + { + if (AndroidSdkInt() < 31) + return false; + + using var audioManager = GetAudioManager(); + using var current = audioManager.Call("getCommunicationDevice"); + int currentRank = current != null ? RouteRank(current.Call("getType")) : int.MaxValue; + + using var devices = audioManager.Call("getAvailableCommunicationDevices"); + int count = devices.Call("size"); + int bestRank = int.MaxValue; + for (int i = 0; i < count; i++) + { + using var device = devices.Call("get", i); + int rank = RouteRank(device.Call("getType")); + if (rank < bestRank) + bestRank = rank; + } + return bestRank < currentRank; + } + catch (Exception e) + { + Debug.LogWarning($"[PlatformAudioController] Route watchdog check failed: {e.Message}"); + return false; + } + } + // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a // documented no-op there): remote tracks play through a voice-communication stream, // whose default route is the earpiece. Pick the best route per RouteRank via @@ -455,7 +547,7 @@ static void ClearAndroidCommunicationRoute() public void Dispose() { Unpublish(); - StopRecordingIfActive(); + StopCapture(); _platformAudio?.Dispose(); _platformAudio = null; From b12d75b41e2538837a61221b7e6dc7e43eb63934 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:36 +0200 Subject: [PATCH 08/35] Fix iOS audio: app-owned manual audio session for stable playout On iOS the WebRTC ADM ran the shared AVAudioSession in automatic mode, so joining a call rerouted other app audio to the earpiece and hanging up deactivated the session out from under Unity (ambient audio died). Put RTCAudioSession into manual mode and have the app own the session: hold one permanent activation (so WebRTC's per-call setActive:NO never deactivates it), set PlayAndRecord + VideoChat (loudspeaker by default), and gate the VPIO unit via isAudioEnabled around connect/disconnect. Also restore the session on PlatformAudio.Dispose (previously dead code). Validated: Meet sample compiles for iOS (Unity 6000.3.10f1). Manual-mode audio behavior needs on-device validation. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtime/Plugins/iOS/LiveKitAudioSession.mm | 191 +++++++++++++++++--- Runtime/Scripts/Audio/PlatformAudio.cs | 40 ++++ Samples~/Meet/Assets/Runtime/MeetManager.cs | 21 ++- 3 files changed, 221 insertions(+), 31 deletions(-) diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index f341fc56..edb9dc3c 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -16,51 +16,182 @@ #import +// This plugin coordinates the single shared AVAudioSession with WebRTC's iOS +// Audio Device Module (ADM). WebRTC ships an RTCAudioSession proxy that, left in +// its default "automatic" mode, reconfigures the category/route and *deactivates* +// the session whenever a call's playout/recording starts and stops. That fights +// with Unity/FMOD: on join the app's other audio (e.g. ambient music) is rerouted +// to the earpiece and attenuated, and on hang-up the session is deactivated out +// from under Unity so its audio dies. +// +// To make playout stable and keep Unity audio alive across call state, we put +// RTCAudioSession into MANUAL mode and have the app own the session: +// * We hold exactly one permanent activation (setActive:YES). Because +// RTCAudioSession ref-counts activation, WebRTC's per-call setActive:YES/NO +// only cycles the count and never actually deactivates the hardware session. +// * We set the category once (PlayAndRecord + VideoChat mode). VideoChat routes +// to the loudspeaker by default (while still honoring connected wired/Bluetooth +// headphones), so WebRTC re-applying its own config keeps output on the speaker +// instead of the earpiece. We deliberately do NOT force the speaker via +// overrideOutputAudioPort, which would override plugged-in headphones. +// * The VPIO voice-processing unit (hardware AEC/AGC/NS) is gated by +// isAudioEnabled. It defaults to YES so call audio works out of the box (the +// unit still only initializes once a call actually has an audio track, so +// pre-call audio is unaffected). Callers can toggle it via +// LiveKit_SetAudioEnabled -- e.g. OFF on hang-up so the unit stops between +// calls while our held activation keeps the session alive for Unity. +// +// RTCAudioSession lives inside the statically-linked liblivekit_ffi; we reach it +// dynamically via NSClassFromString + a protocol-typed id so this file never +// creates a link-time dependency on the class. If the class can't be found we +// fall back to configuring AVAudioSession directly (legacy behavior). + +/// Minimal subset of WebRTC's RTCAudioSession that we message dynamically. +@protocol LiveKitRTCAudioSession +@property(nonatomic, assign) BOOL useManualAudio; +@property(nonatomic, assign) BOOL isAudioEnabled; +@property(nonatomic, readonly) int activationCount; +- (void)lockForConfiguration; +- (void)unlockForConfiguration; +- (BOOL)setActive:(BOOL)active error:(NSError**)outError; +- (BOOL)setCategory:(AVAudioSessionCategory)category + mode:(AVAudioSessionMode)mode + options:(AVAudioSessionCategoryOptions)options + error:(NSError**)outError; +@end + +// Tracks whether *we* currently hold the one app-owned activation, so we add and +// release it exactly once regardless of how many times configure/restore run. +static BOOL s_liveKitHoldsActivation = NO; + +static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions = + AVAudioSessionCategoryOptionDefaultToSpeaker | + AVAudioSessionCategoryOptionAllowBluetooth | + AVAudioSessionCategoryOptionAllowBluetoothA2DP; + +/// Returns WebRTC's shared RTCAudioSession if it's present in the linked binary, +/// or nil if the class can't be found (in which case callers use AVAudioSession). +static id LiveKit_RTCSession() { + Class cls = NSClassFromString(@"RTCAudioSession"); + if (!cls || ![cls respondsToSelector:@selector(sharedInstance)]) { + return nil; + } +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + return (id)[cls performSelector:@selector(sharedInstance)]; +#pragma clang diagnostic pop +} + extern "C" { -/// Configures the iOS audio session for VoIP/WebRTC use. -/// This sets AVAudioSessionCategoryPlayAndRecord with VoiceChat mode, -/// which enables the VPIO (Voice Processing IO) AudioUnit for: -/// - Hardware echo cancellation (AEC) -/// - Automatic gain control (AGC) -/// - Noise suppression (NS) +/// Configures the iOS audio session for VoIP/WebRTC use and takes app ownership +/// of the shared AVAudioSession. /// -/// Call this before creating PlatformAudio to ensure WebRTC can -/// properly initialize the microphone and speaker. +/// This sets AVAudioSessionCategoryPlayAndRecord with VideoChat mode (which routes +/// to the loudspeaker by default and enables the VPIO Voice Processing IO unit for +/// hardware AEC/AGC/NS), puts RTCAudioSession into manual mode, and holds a single +/// permanent activation so WebRTC never deactivates the session on its own. +/// +/// Call this before creating PlatformAudio. Call audio is enabled by default, so +/// no further call is required for it to work; use LiveKit_SetAudioEnabled(false) +/// to stop the VPIO unit between calls (e.g. on hang-up). void LiveKit_ConfigureAudioSessionForVoIP() { - AVAudioSession* session = [AVAudioSession sharedInstance]; - NSError* error = nil; - - // Configure for VoIP with echo cancellation - BOOL success = [session setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVoiceChat - options:AVAudioSessionCategoryOptionDefaultToSpeaker | - AVAudioSessionCategoryOptionAllowBluetooth | - AVAudioSessionCategoryOptionAllowBluetoothA2DP - error:&error]; + id rtc = LiveKit_RTCSession(); - if (!success || error) { - NSLog(@"LiveKit: Failed to configure VoIP audio session: %@", error.localizedDescription); + if (rtc == nil) { + // RTCAudioSession unavailable: configure AVAudioSession directly (legacy). + AVAudioSession* session = [AVAudioSession sharedInstance]; + NSError* error = nil; + if (![session setCategory:AVAudioSessionCategoryPlayAndRecord + mode:AVAudioSessionModeVideoChat + options:kLiveKitCategoryOptions + error:&error] || error) { + NSLog(@"LiveKit: Failed to configure audio session: %@", error.localizedDescription); + return; + } + if (![session setActive:YES error:&error] || error) { + NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); + return; + } + NSLog(@"LiveKit: Audio session configured (AVAudioSession fallback, VideoChat)"); return; } - // Activate the audio session - success = [session setActive:YES error:&error]; - if (!success || error) { - NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); - return; + // Manual mode: WebRTC won't activate/deactivate the session on its own, and + // won't initialize the VPIO unit until we grant permission via isAudioEnabled + // (set below). This is what lets us own activation and gate the unit. + rtc.useManualAudio = YES; + + [rtc lockForConfiguration]; + NSError* error = nil; + if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord + mode:AVAudioSessionModeVideoChat + options:kLiveKitCategoryOptions + error:&error] || error) { + NSLog(@"LiveKit: Failed to set audio category: %@", error.localizedDescription); } - NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VoiceChat mode)"); + // Hold exactly one app-owned activation. RTCAudioSession ref-counts activation, + // so WebRTC's balanced setActive:YES/NO during a call never drops the real + // session below active while we hold this. + if (!s_liveKitHoldsActivation) { + error = nil; + if ([rtc setActive:YES error:&error] && !error) { + s_liveKitHoldsActivation = YES; + } else { + NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); + } + } + + [rtc unlockForConfiguration]; + + // Grant WebRTC permission to initialize its audio unit by default so call audio + // works without an explicit LiveKit_SetAudioEnabled(true). The unit is only + // actually created once a call has an audio track, so pre-call audio is + // unaffected. Callers may still disable it (e.g. on hang-up) via + // LiveKit_SetAudioEnabled(false). + rtc.isAudioEnabled = YES; + + NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VideoChat, manual mode, activationCount=%d)", + rtc.activationCount); } -/// Restores the audio session to the default ambient category. -/// Call this when PlatformAudio is disposed if you want to restore -/// the original audio behavior. +/// Enables or disables WebRTC's VPIO audio unit while the app keeps ownership of +/// the session. Pass true when a call connects and false when it ends. +/// +/// This is only effective in manual mode (set up by LiveKit_ConfigureAudioSessionForVoIP). +/// Disabling on hang-up stops incoming/outgoing call audio and the VPIO processing, +/// but leaves the session active (via the app's held activation), so Unity audio +/// keeps playing. +void LiveKit_SetAudioEnabled(bool enabled) { + id rtc = LiveKit_RTCSession(); + if (rtc == nil) { + return; + } + rtc.isAudioEnabled = enabled ? YES : NO; + NSLog(@"LiveKit: isAudioEnabled=%@ (activationCount=%d)", enabled ? @"YES" : @"NO", rtc.activationCount); +} + +/// Restores the audio session to the default ambient category and relinquishes the +/// app-owned activation and manual mode. Call this when PlatformAudio is disposed. void LiveKit_RestoreDefaultAudioSession() { + id rtc = LiveKit_RTCSession(); + + if (rtc != nil) { + // Stop the VPIO unit and release our activation before handing control back. + rtc.isAudioEnabled = NO; + if (s_liveKitHoldsActivation) { + NSError* error = nil; + if (![rtc setActive:NO error:&error] || error) { + NSLog(@"LiveKit: Failed to deactivate audio session: %@", error.localizedDescription); + } + s_liveKitHoldsActivation = NO; + } + rtc.useManualAudio = NO; + } + AVAudioSession* session = [AVAudioSession sharedInstance]; NSError* error = nil; - [session setCategory:AVAudioSessionCategoryAmbient error:&error]; if (error) { NSLog(@"LiveKit: Failed to restore default audio session: %@", error.localizedDescription); diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 7c113e20..9e8685c5 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -31,6 +31,15 @@ internal static class IOSAudioSessionHelper /// [DllImport("__Internal")] internal static extern void LiveKit_RestoreDefaultAudioSession(); + + /// + /// Enables or disables WebRTC's VPIO audio unit while the app keeps + /// ownership of the audio session. Enable when a call connects, disable + /// when it ends. Disabling on hang-up stops call audio without + /// deactivating the session, so other app audio keeps playing. + /// + [DllImport("__Internal")] + internal static extern void LiveKit_SetAudioEnabled([MarshalAs(UnmanagedType.I1)] bool enabled); } #endif @@ -354,6 +363,28 @@ public void StopRecording() Utils.Debug("PlatformAudio: stopped recording"); } + /// + /// Signals whether call audio should be active on the platform audio session. + /// + /// On iOS this gates WebRTC's VPIO audio unit while the app retains ownership + /// of the shared AVAudioSession. It is enabled by default when PlatformAudio is + /// created, so this only needs to be called to false when leaving a room + /// (and back to true when rejoining). Disabling stops the microphone/ + /// remote audio path and the hardware voice processing, but keeps the audio + /// session active so other Unity audio (e.g. background music) is not + /// interrupted — which is why Unity audio survives a hang-up. + /// + /// On other platforms this is a no-op: the OS/ADM manages the session directly. + /// + /// True while a call is active, false otherwise. + public void SetSessionAudioEnabled(bool enabled) + { +#if UNITY_IOS && !UNITY_EDITOR + IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled); +#endif + Utils.Debug($"PlatformAudio: session audio enabled={enabled}"); + } + /// /// Releases the PlatformAudio resources. /// @@ -364,6 +395,15 @@ public void Dispose() { if (_disposed) return; Handle.Dispose(); + +#if UNITY_IOS && !UNITY_EDITOR + // Relinquish the app-owned audio session: disable call audio, release + // our activation, leave manual mode, and restore the ambient category. + // Balances the LiveKit_ConfigureAudioSessionForVoIP() call made in the + // constructor so the session isn't left stuck in PlayAndRecord. + IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession(); +#endif + _disposed = true; Utils.Debug("PlatformAudio disposed"); } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index 1dc27c71..c47c5538 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -168,6 +168,11 @@ private void OnEndCall() { if (_room == null) return; + // Disable call audio while keeping the app-owned audio session active, so + // Unity audio (e.g. background music) survives the hang-up on iOS. + if (usePlatformAudio) + _platformAudio?.SetSessionAudioEnabled(false); + _room.Disconnect(); CleanUpAllTracks(); _room = null; @@ -249,6 +254,13 @@ private IEnumerator ConnectToRoom() _localId = _room.LocalParticipant.Identity; buttonBar.SetConnected(true); + // Enable call audio now that we're in a room. On iOS this turns on WebRTC's + // VPIO unit while the app keeps ownership of the audio session; leaving the + // room disables it again (see OnEndCall / OnDisconnected) so other Unity + // audio keeps playing. + if (usePlatformAudio) + _platformAudio?.SetSessionAudioEnabled(true); + EnsureParticipantTile(_localId); foreach (var remote in _room.RemoteParticipants.Values) EnsureParticipantTile(remote.Identity); @@ -433,7 +445,14 @@ private void OnParticipantDisconnected(Participant participant, DisconnectReason } private void OnDisconnected(Room room) - => Debug.Log($"Disconnected from room: {room.DisconnectReason}"); + { + Debug.Log($"Disconnected from room: {room.DisconnectReason}"); + + // Covers server-initiated disconnects as well as OnEndCall; idempotent with + // the call already made there. Keeps the audio session active for Unity. + if (usePlatformAudio) + _platformAudio?.SetSessionAudioEnabled(false); + } private void OnTrackMuted(TrackPublication publication, Participant participant) { From 0310db1283a9626bb7b2908aa8ad78c99e320da7 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:34:54 +0200 Subject: [PATCH 09/35] Restore cached iOS audio session on last PlatformAudio dispose Snapshot the app's audio session category/mode/options the first time LiveKit configures the session, and on the last PlatformAudio dispose (Interlocked instance counter) restore that snapshot and reactivate the session with setActive:YES so Unity audio output resumes. Previously the restore path hardcoded the Ambient category and left the session deactivated, which killed Unity audio at dispose time. Also removes the now-fixed README known issue and updates stale VoiceChat references in doc comments (the session uses VideoChat mode). Co-Authored-By: Claude Fable 5 --- README.md | 1 - Runtime/Plugins/iOS/LiveKitAudioSession.mm | 59 ++++++++++++++++++++-- Runtime/Scripts/Audio/PlatformAudio.cs | 31 +++++++++--- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 284dee4c..d064d46a 100644 --- a/README.md +++ b/README.md @@ -330,7 +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 diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index edb9dc3c..74f63de5 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -64,6 +64,15 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category // release it exactly once regardless of how many times configure/restore run. static BOOL s_liveKitHoldsActivation = NO; +// Snapshot of the AVAudioSession configuration as it was the first time LiveKit +// touched the session (i.e. whatever Unity set up from its iOS Player Settings). +// Captured lazily in LiveKit_ConfigureAudioSessionForVoIP and re-applied by +// LiveKit_RestoreDefaultAudioSession when the last PlatformAudio is disposed. +static BOOL s_hasCachedState = NO; +static NSString* s_cachedCategory = nil; +static NSString* s_cachedMode = nil; +static AVAudioSessionCategoryOptions s_cachedCategoryOptions = 0; + static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth | @@ -82,6 +91,23 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category #pragma clang diagnostic pop } +/// Captures the current audio session category/mode/options exactly once, before +/// LiveKit reconfigures the session for VoIP. Subsequent calls are no-ops so the +/// snapshot always reflects the pristine, pre-LiveKit (Unity-configured) state. +static void LiveKit_CacheSessionStateIfNeeded() { + if (s_hasCachedState) { + return; + } + AVAudioSession* session = [AVAudioSession sharedInstance]; + // copy so the strings persist for the app lifetime regardless of ARC/MRC. + s_cachedCategory = [session.category copy]; + s_cachedMode = [session.mode copy]; + s_cachedCategoryOptions = session.categoryOptions; + s_hasCachedState = YES; + NSLog(@"LiveKit: cached audio session state: category=%@, mode=%@, options=%lu", + s_cachedCategory, s_cachedMode, (unsigned long)s_cachedCategoryOptions); +} + extern "C" { /// Configures the iOS audio session for VoIP/WebRTC use and takes app ownership @@ -96,6 +122,9 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category /// no further call is required for it to work; use LiveKit_SetAudioEnabled(false) /// to stop the VPIO unit between calls (e.g. on hang-up). void LiveKit_ConfigureAudioSessionForVoIP() { + // Snapshot the pristine (Unity Player Settings) session before we change it. + LiveKit_CacheSessionStateIfNeeded(); + id rtc = LiveKit_RTCSession(); if (rtc == nil) { @@ -172,8 +201,10 @@ void LiveKit_SetAudioEnabled(bool enabled) { NSLog(@"LiveKit: isAudioEnabled=%@ (activationCount=%d)", enabled ? @"YES" : @"NO", rtc.activationCount); } -/// Restores the audio session to the default ambient category and relinquishes the -/// app-owned activation and manual mode. Call this when PlatformAudio is disposed. +/// Restores the audio session Unity had before LiveKit touched it (or the ambient +/// category if LiveKit never configured it), relinquishes the app-owned activation +/// and manual mode, and reactivates the session so Unity audio output resumes. +/// Call this when the last PlatformAudio is disposed. void LiveKit_RestoreDefaultAudioSession() { id rtc = LiveKit_RTCSession(); @@ -192,9 +223,27 @@ void LiveKit_RestoreDefaultAudioSession() { AVAudioSession* session = [AVAudioSession sharedInstance]; NSError* error = nil; - [session setCategory:AVAudioSessionCategoryAmbient error:&error]; - if (error) { - NSLog(@"LiveKit: Failed to restore default audio session: %@", error.localizedDescription); + if (s_hasCachedState) { + if (![session setCategory:s_cachedCategory + mode:s_cachedMode + options:s_cachedCategoryOptions + error:&error] || error) { + NSLog(@"LiveKit: Failed to restore cached audio session (category=%@, mode=%@): %@", + s_cachedCategory, s_cachedMode, error.localizedDescription); + } + } else { + // Configure was never called, so we have nothing to restore to; fall back + // to the ambient category. + [session setCategory:AVAudioSessionCategoryAmbient error:&error]; + if (error) { + NSLog(@"LiveKit: Failed to restore default audio session: %@", error.localizedDescription); + } + } + + // Hand an active session back to Unity so its audio output resumes. + error = nil; + if (![session setActive:YES error:&error] || error) { + NSLog(@"LiveKit: Failed to reactivate audio session: %@", error.localizedDescription); } } diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 9e8685c5..cfe13639 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -27,7 +27,9 @@ internal static class IOSAudioSessionHelper internal static extern void LiveKit_ConfigureAudioSessionForVoIP(); /// - /// Restores the iOS audio session to ambient mode. + /// Restores the audio session Unity had before LiveKit configured it + /// (or the ambient category as a fallback) and reactivates it so Unity + /// audio output resumes. Called when the last PlatformAudio is disposed. /// [DllImport("__Internal")] internal static extern void LiveKit_RestoreDefaultAudioSession(); @@ -83,6 +85,11 @@ public sealed class PlatformAudio : IDisposable internal readonly FfiHandle Handle; private readonly PlatformAudioInfo _info; private bool _disposed = false; +#if UNITY_IOS && !UNITY_EDITOR + // Tracks live PlatformAudio instances so the iOS audio session is restored + // only when the last one is disposed (aligned with the native ADM ref-count). + private static int _instanceCount; +#endif /// /// Number of available recording (microphone) devices. @@ -101,7 +108,7 @@ public sealed class PlatformAudio : IDisposable /// to a room if you want automatic speaker playout for remote audio. /// /// On iOS, this automatically configures the audio session for VoIP mode - /// (PlayAndRecord category with VoiceChat mode) to enable hardware echo + /// (PlayAndRecord category with VideoChat mode) to enable hardware echo /// cancellation and microphone input. /// /// @@ -112,7 +119,7 @@ public PlatformAudio() { #if UNITY_IOS && !UNITY_EDITOR // Configure iOS audio session for VoIP before initializing WebRTC ADM. - // This sets PlayAndRecord category with VoiceChat mode for hardware AEC. + // This sets PlayAndRecord category with VideoChat mode for hardware AEC. IOSAudioSessionHelper.LiveKit_ConfigureAudioSessionForVoIP(); #endif @@ -128,6 +135,12 @@ public PlatformAudio() _info = platformAudio.Info; Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices"); + +#if UNITY_IOS && !UNITY_EDITOR + // Count this instance only after successful construction so a failed + // ctor never leaves the counter stuck above zero. + System.Threading.Interlocked.Increment(ref _instanceCount); +#endif } /// @@ -397,11 +410,13 @@ public void Dispose() Handle.Dispose(); #if UNITY_IOS && !UNITY_EDITOR - // Relinquish the app-owned audio session: disable call audio, release - // our activation, leave manual mode, and restore the ambient category. - // Balances the LiveKit_ConfigureAudioSessionForVoIP() call made in the - // constructor so the session isn't left stuck in PlayAndRecord. - IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession(); + // Once the last instance is gone, relinquish the app-owned audio session: + // disable call audio, release our activation, leave manual mode, restore + // the session Unity had before LiveKit touched it, and reactivate it so + // Unity audio output resumes. Balances LiveKit_ConfigureAudioSessionForVoIP() + // in the constructor so the session isn't left stuck in PlayAndRecord. + if (System.Threading.Interlocked.Decrement(ref _instanceCount) == 0) + IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession(); #endif _disposed = true; From 8d78d1f5a9237b8005d7cccdec8f9a78e7fae46c Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:20:12 +0200 Subject: [PATCH 10/35] Recover iOS platform audio after backgrounding Backgrounding interrupts the shared AVAudioSession and WebRTC stops its VPIO unit. On foreground RTCAudioSession restarts the unit exactly once, with no retry, while Unity/FMOD restarts its own audio around the same moment and can reconfigure the shared session underneath it (observed on Unity 6; Unity 2022 happens to win the race). In manual audio mode nothing else ever restarts the unit, leaving calls with no audio output and no mic input. Observe UIApplicationDidBecomeActive and interruption-ended in the plugin and, after Unity's delayed restart has settled, re-assert LiveKit's category/mode/options, reactivate the session, and cycle isAudioEnabled to force a clean rebuild of the audio unit. Logs the session state before the re-assert so device tests can see who won the focus race. Co-Authored-By: Claude Fable 5 --- Runtime/Plugins/iOS/LiveKitAudioSession.mm | 120 +++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index 74f63de5..e3907cba 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -15,6 +15,7 @@ */ #import +#import // This plugin coordinates the single shared AVAudioSession with WebRTC's iOS // Audio Device Module (ADM). WebRTC ships an RTCAudioSession proxy that, left in @@ -40,6 +41,13 @@ // pre-call audio is unaffected). Callers can toggle it via // LiveKit_SetAudioEnabled -- e.g. OFF on hang-up so the unit stops between // calls while our held activation keeps the session alive for Unity. +// * Backgrounding interrupts the session and WebRTC stops its audio unit. On +// foreground, RTCAudioSession's own recovery restarts the unit exactly once, +// with no retry -- and Unity/FMOD restarts *its* audio around the same moment, +// reconfiguring the shared session (observed with Unity 6). Whoever loses that +// race stays broken, so we observe foreground/interruption-end ourselves and, +// after Unity's restart has settled, re-assert our config and cycle +// isAudioEnabled to force a clean rebuild of the audio unit. // // RTCAudioSession lives inside the statically-linked liblivekit_ffi; we reach it // dynamically via NSClassFromString + a protocol-typed id so this file never @@ -73,6 +81,16 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category static NSString* s_cachedMode = nil; static AVAudioSessionCategoryOptions s_cachedCategoryOptions = 0; +// YES between configure and restore: gates the foreground-recovery observers so +// they no-op once LiveKit has handed the session back to the app. +static BOOL s_liveKitConfigured = NO; +// The isAudioEnabled state the caller wants (updated by LiveKit_SetAudioEnabled), +// so recovery knows whether to restart the audio unit after re-asserting config. +static BOOL s_audioDesired = NO; +// Coalesces recovery requests (didBecomeActive and interruption-ended both fire +// on foreground) into one delayed pass. +static BOOL s_recoveryPending = NO; + static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetooth | @@ -91,6 +109,99 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category #pragma clang diagnostic pop } +/// Re-applies LiveKit's category/mode/options and reactivates the session, then +/// cycles isAudioEnabled to force WebRTC to rebuild its VPIO audio unit. Runs on +/// a delay so it lands after Unity/FMOD's own foreground audio restart (which is +/// itself delayed and can reconfigure the shared session underneath WebRTC's +/// one-shot, no-retry interruption recovery -- the Unity 6 focus race). +static void LiveKit_ScheduleSessionRecovery() { + if (!s_liveKitConfigured || s_recoveryPending) { + return; + } + s_recoveryPending = YES; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + s_recoveryPending = NO; + if (!s_liveKitConfigured) { + return; // restored/disposed while the recovery was pending + } + + AVAudioSession* session = [AVAudioSession sharedInstance]; + // "Who won" the focus race: what Unity/FMOD left the session as. + NSLog(@"LiveKit: foreground recovery; session before re-assert: category=%@ mode=%@ options=%lu", + session.category, session.mode, (unsigned long)session.categoryOptions); + + id rtc = LiveKit_RTCSession(); + NSError* error = nil; + if (rtc != nil) { + [rtc lockForConfiguration]; + if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord + mode:AVAudioSessionModeVideoChat + options:kLiveKitCategoryOptions + error:&error] || error) { + NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); + } + [rtc unlockForConfiguration]; + } else { + if (![session setCategory:AVAudioSessionCategoryPlayAndRecord + mode:AVAudioSessionModeVideoChat + options:kLiveKitCategoryOptions + error:&error] || error) { + NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); + } + } + + // Reactivate directly on AVAudioSession: the OS deactivated the hardware + // session during the interruption, but RTCAudioSession's activation + // ref-count still includes our held activation, so reactivating through + // the proxy would double-count it. + error = nil; + if (![session setActive:YES error:&error] || error) { + NSLog(@"LiveKit: recovery failed to reactivate session: %@", error.localizedDescription); + } + + // Cycle isAudioEnabled to rebuild the VPIO unit against the re-asserted + // session (WebRTC's own foreground restart may have failed, or been undone + // by Unity's). Harmless when no call audio is active: with playout and + // recording uninitialized WebRTC ignores the change. + if (rtc != nil && s_audioDesired) { + rtc.isAudioEnabled = NO; + rtc.isAudioEnabled = YES; + } + + NSLog(@"LiveKit: foreground recovery done (audioDesired=%d, activationCount=%d)", + s_audioDesired, rtc != nil ? rtc.activationCount : -1); + }); +} + +/// Registers app-lifetime observers that trigger session recovery when the app +/// returns to the foreground or an audio interruption ends. Registered once on +/// first configure; the handlers no-op while LiveKit is not configured. +static void LiveKit_RegisterLifecycleObserversIfNeeded() { + static BOOL s_observersRegistered = NO; + if (s_observersRegistered) { + return; + } + s_observersRegistered = YES; + + NSNotificationCenter* center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + LiveKit_ScheduleSessionRecovery(); + }]; + [center addObserverForName:AVAudioSessionInterruptionNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + NSNumber* type = note.userInfo[AVAudioSessionInterruptionTypeKey]; + if (type.unsignedIntegerValue == AVAudioSessionInterruptionTypeEnded) { + LiveKit_ScheduleSessionRecovery(); + } + }]; +} + /// Captures the current audio session category/mode/options exactly once, before /// LiveKit reconfigures the session for VoIP. Subsequent calls are no-ops so the /// snapshot always reflects the pristine, pre-LiveKit (Unity-configured) state. @@ -125,6 +236,10 @@ void LiveKit_ConfigureAudioSessionForVoIP() { // Snapshot the pristine (Unity Player Settings) session before we change it. LiveKit_CacheSessionStateIfNeeded(); + LiveKit_RegisterLifecycleObserversIfNeeded(); + s_liveKitConfigured = YES; + s_audioDesired = YES; // mirrors the isAudioEnabled default set below + id rtc = LiveKit_RTCSession(); if (rtc == nil) { @@ -193,6 +308,7 @@ void LiveKit_ConfigureAudioSessionForVoIP() { /// but leaves the session active (via the app's held activation), so Unity audio /// keeps playing. void LiveKit_SetAudioEnabled(bool enabled) { + s_audioDesired = enabled ? YES : NO; id rtc = LiveKit_RTCSession(); if (rtc == nil) { return; @@ -206,6 +322,10 @@ void LiveKit_SetAudioEnabled(bool enabled) { /// and manual mode, and reactivates the session so Unity audio output resumes. /// Call this when the last PlatformAudio is disposed. void LiveKit_RestoreDefaultAudioSession() { + // Stand down the foreground-recovery observers before touching the session. + s_liveKitConfigured = NO; + s_audioDesired = NO; + id rtc = LiveKit_RTCSession(); if (rtc != nil) { From a2607ec1b92391cbf0f9d9f8bb74d78b89c25b66 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:36:07 +0200 Subject: [PATCH 11/35] Remove fixed Unity 6 backgrounding known issue from README Device-verified: with the foreground recovery in place, platform audio survives background/foreground on Unity 6, and the change is backwards compatible with Unity 2022.3. Co-Authored-By: Claude Fable 5 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d064d46a..6a0ff028 100644 --- a/README.md +++ b/README.md @@ -330,7 +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 and Unity 6, backgrounding the app breaks Platform Audio - On MacOS with bluetooth headset, unmuting can break audio output #### Initialize Platform Audio From e5cecc62873671f0464a561e57de833e71de7831 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:43:13 +0200 Subject: [PATCH 12/35] Add public audio output routing API surface (PAR-019) Define the phase-A routing API on PlatformAudio, purely in C# and shaped identically to the planned FFI-backed implementation so app code written against it survives the plumbing swap: - AudioOutputKind enum (values mirror the planned FFI proto enum 1:1) - AudioDevice.Kind / AudioDevice.IsSelected - OutputPreference ranked policy (default BT > wired > speaker > earpiece) - IsSpeakerOutputPreferred as documented sugar over the list order - SelectOutput / ClearOutputOverride sticky override - DevicesChanged event (playout, recording) on the Unity main thread - internal IRouteController seam with desktop (FFI enumeration/GUID selection) and unsupported-mobile implementations; Android/iOS backends plug into the seam in follow-up work Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/Audio/PlatformAudio.cs | 268 ++++++++++++++++++ Runtime/Scripts/Audio/RouteController.cs | 137 +++++++++ Runtime/Scripts/Audio/RouteController.cs.meta | 11 + Tests/PlayMode/PlatformAudioTests.cs | 120 ++++++++ 4 files changed, 536 insertions(+) create mode 100644 Runtime/Scripts/Audio/RouteController.cs create mode 100644 Runtime/Scripts/Audio/RouteController.cs.meta diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 7c113e20..414ef160 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Threading; using LiveKit.Proto; using LiveKit.Internal; using LiveKit.Internal.FFI.Requests; @@ -34,6 +35,31 @@ internal static class IOSAudioSessionHelper } #endif + /// + /// The kind of audio output device, used for ranked routing policies on mobile + /// platforms (see ). + /// + /// The numeric values mirror the planned FFI protocol enum (AudioDeviceKind) one-to-one + /// so a future FFI-backed implementation maps without translation. Do not renumber. + /// + public enum AudioOutputKind + { + /// The platform did not report a device type. + Unknown = 0, + /// The phone's built-in earpiece (receiver). + Earpiece = 1, + /// The built-in loudspeaker. + Speaker = 2, + /// A wired headset or headphones. + WiredHeadset = 3, + /// A Bluetooth audio device. + Bluetooth = 4, + /// A USB audio device. + Usb = 5, + /// A hearing aid. + HearingAid = 6, + } + /// /// Information about an audio device (microphone or speaker). /// @@ -49,6 +75,17 @@ public struct AudioDevice /// over index for device selection. /// public string Guid; + /// + /// The kind of output this device represents. + /// where the platform does not report a type — currently all devices: no routing + /// backend classifies devices yet. + /// + public AudioOutputKind Kind; + /// + /// Whether this device is the active output route. Only meaningful once a platform + /// routing backend reports selection state — currently always false. + /// + public bool IsSelected; } /// @@ -73,8 +110,19 @@ public sealed class PlatformAudio : IDisposable { internal readonly FfiHandle Handle; private readonly PlatformAudioInfo _info; + private readonly IRouteController _routeController; + private readonly SynchronizationContext _syncContext; + private List _outputPreference = new List(DefaultOutputPreference); private bool _disposed = false; + private static readonly AudioOutputKind[] DefaultOutputPreference = + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + }; + /// /// Number of available recording (microphone) devices. /// @@ -118,9 +166,24 @@ public PlatformAudio() Handle = FfiHandle.FromOwnedHandle(platformAudio.Handle); _info = platformAudio.Info; + _syncContext = SynchronizationContext.Current; + _routeController = CreateRouteController(); + _routeController.DevicesChanged += OnRouteControllerDevicesChanged; + Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices"); } + private IRouteController CreateRouteController() + { +#if UNITY_ANDROID && !UNITY_EDITOR + return new UnsupportedRouteController(this, "Android"); +#elif UNITY_IOS && !UNITY_EDITOR + return new UnsupportedRouteController(this, "iOS"); +#else + return new DesktopRouteController(this); +#endif + } + /// /// Gets the lists of available recording and playout devices. /// @@ -146,6 +209,16 @@ public PlatformAudio() /// Thrown if device enumeration failed. /// public (List Recording, List Playout) GetDevices() + { + return _routeController.GetDevices(); + } + + /// + /// Device enumeration through the FFI, shared by the route controllers. + /// and are not + /// reported by the FFI and stay at their defaults (Unknown / false). + /// + internal (List Recording, List Playout) GetDevicesViaFfi() { using var request = FFIBridge.Instance.NewRequest(); request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle(); @@ -179,6 +252,199 @@ public PlatformAudio() return (recording, playout); } + /// + /// Ranked automatic output routing policy, most preferred first. When no explicit + /// output override is active (), the platform routes to + /// the highest-ranked kind that has a connected device. + /// + /// Default: Bluetooth > WiredHeadset > Speaker > Earpiece. + /// + /// Precedence with : this list is the single + /// source of truth; the bool is convenience sugar that only rewrites the relative + /// order of and + /// inside this list, and reading the bool + /// reads their current relative order. There is no separate speaker-preference state. + /// + /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority + /// over the built-in outputs, so the Speaker/Earpiece relative order — i.e. + /// — is the only part of the ranking with an + /// effect. On Android the full ranking applies. On desktop, output is selected + /// per device ( / ) + /// and the ranking has no routing effect. The mobile routing backends are not + /// implemented yet in this version: on Android and iOS the value is currently + /// stored and round-trips, but has no routing effect either. + /// + /// Thrown if set to null. + /// + /// Thrown if the list contains or duplicates. + /// + public IReadOnlyList OutputPreference + { + get => _outputPreference.AsReadOnly(); + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + var ranked = new List(value.Count); + foreach (var kind in value) + { + if (kind == AudioOutputKind.Unknown) + throw new ArgumentException( + "OutputPreference cannot contain AudioOutputKind.Unknown", nameof(value)); + if (ranked.Contains(kind)) + throw new ArgumentException( + $"OutputPreference contains {kind} more than once", nameof(value)); + ranked.Add(kind); + } + + _outputPreference = ranked; + _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly()); + } + } + + /// + /// Whether the loudspeaker is preferred over the earpiece for automatic routing. + /// + /// Precedence with : the list is the single source of + /// truth; this bool is convenience sugar that only rewrites the relative order of + /// and + /// inside , and reading it reads their current + /// relative order. There is no separate speaker-preference state. Reading returns + /// true when Speaker ranks ahead of Earpiece (or Earpiece is absent), false when + /// Speaker is absent. Setting reorders the pair in place at the position of + /// whichever currently ranks first, inserting a missing kind next to the present + /// one (or appending both when neither is listed) so the value round-trips. + /// + /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority + /// over the built-in outputs, so this bool is the only part of the ranking with an + /// effect. On Android the full ranking applies. On desktop, output is selected + /// per device ( / ) + /// and the ranking has no routing effect. The mobile routing backends are not + /// implemented yet in this version: on Android and iOS the value is currently + /// stored and round-trips, but has no routing effect either. + /// + public bool IsSpeakerOutputPreferred + { + get + { + var speaker = _outputPreference.IndexOf(AudioOutputKind.Speaker); + var earpiece = _outputPreference.IndexOf(AudioOutputKind.Earpiece); + if (speaker < 0) return false; + return earpiece < 0 || speaker < earpiece; + } + set + { + var first = value ? AudioOutputKind.Speaker : AudioOutputKind.Earpiece; + var second = value ? AudioOutputKind.Earpiece : AudioOutputKind.Speaker; + + var reordered = new List(_outputPreference.Count + 2); + var pairInserted = false; + foreach (var kind in _outputPreference) + { + if (kind == AudioOutputKind.Speaker || kind == AudioOutputKind.Earpiece) + { + if (!pairInserted) + { + reordered.Add(first); + reordered.Add(second); + pairInserted = true; + } + continue; + } + reordered.Add(kind); + } + if (!pairInserted) + { + reordered.Add(first); + reordered.Add(second); + } + + _outputPreference = reordered; + _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly()); + } + } + + /// + /// Routes audio output to the given device as a sticky override of the automatic + /// policy: the route stays on the device until + /// is called. The device is matched against the + /// current playout list by + /// when set, otherwise by index and name. + /// + /// Platform notes: on desktop this selects the device like + /// . On Android and iOS the routing backends + /// are not implemented yet in this version and this method throws + /// . + /// + /// A playout device from . + /// + /// Thrown if the device does not match any current playout device. + /// + /// + /// Thrown on Android and iOS, where no routing backend exists yet. + /// + public void SelectOutput(AudioDevice device) + { + var (_, playout) = GetDevices(); + foreach (var candidate in playout) + { + var matches = !string.IsNullOrEmpty(device.Guid) + ? candidate.Guid == device.Guid + : candidate.Index == device.Index && candidate.Name == device.Name; + if (!matches) continue; + + _routeController.SelectOutput(candidate); + return; + } + + throw new ArgumentException( + $"Device '{device.Name}' (index {device.Index}, guid {device.Guid ?? "none"}) " + + "is not a current playout device", nameof(device)); + } + + /// + /// Clears the sticky override set by so the automatic + /// policy applies again. + /// + /// Platform notes: on desktop there is no automatic policy to fall back to yet, so + /// clearing keeps the currently selected device (no-op). On Android and iOS no + /// override can exist yet ( throws), so this is a no-op + /// there as well. + /// + public void ClearOutputOverride() + { + _routeController.ClearOutputOverride(); + } + + /// + /// Raised when the set of available audio devices changes, with the current playout + /// and recording device lists. Raised on the Unity main thread. + /// + /// No implementation raises this event yet in this version: desktop hot-plug events + /// and the mobile routing backends that produce it are not implemented. Subscribing + /// and unsubscribing is safe at any time, including after . + /// + public event Action, IReadOnlyList> DevicesChanged; + + private void OnRouteControllerDevicesChanged( + IReadOnlyList playout, IReadOnlyList recording) + { + if (_disposed) return; + + if (_syncContext != null && _syncContext != SynchronizationContext.Current) + { + _syncContext.Post(_ => + { + if (!_disposed) + DevicesChanged?.Invoke(playout, recording); + }, null); + return; + } + + DevicesChanged?.Invoke(playout, recording); + } + /// /// Sets the recording device (microphone) by index. /// @@ -363,6 +629,8 @@ public void StopRecording() public void Dispose() { if (_disposed) return; + _routeController.DevicesChanged -= OnRouteControllerDevicesChanged; + _routeController.Dispose(); Handle.Dispose(); _disposed = true; Utils.Debug("PlatformAudio disposed"); diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs new file mode 100644 index 00000000..31d7861b --- /dev/null +++ b/Runtime/Scripts/Audio/RouteController.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; + +namespace LiveKit +{ + /// + /// Backend seam for audio output routing. registers one + /// implementation per platform and forwards its public routing API + /// (, , + /// , , + /// ) through it, so the plumbing can be swapped + /// per platform — and later wholesale for an FFI-backed implementation — without changing + /// a public signature. + /// + internal interface IRouteController : IDisposable + { + /// Snapshot of the current recording and playout device lists. + (List Recording, List Playout) GetDevices(); + + /// Applies the ranked automatic output policy, most preferred first. + void ApplyOutputPreference(IReadOnlyList ranked); + + /// + /// Routes output to the given device as a sticky override of the automatic policy. + /// The device has already been validated against the current playout snapshot. + /// + void SelectOutput(AudioDevice device); + + /// Clears the sticky override so the automatic policy applies again. + void ClearOutputOverride(); + + /// + /// Raised when the available devices change, with the current (playout, recording) + /// lists. May be raised from any thread; marshals it to + /// the Unity main thread before re-raising publicly. + /// + event Action, IReadOnlyList> DevicesChanged; + } + + /// + /// Desktop routing backend: wraps the FFI device enumeration and per-device GUID + /// selection. Ranked-kind policy is not implemented on desktop (output is chosen per + /// device), and no desktop hot-plug events exist yet, so + /// is never raised. + /// + internal sealed class DesktopRouteController : IRouteController + { + private readonly PlatformAudio _owner; + + public DesktopRouteController(PlatformAudio owner) + { + _owner = owner; + } + + public (List Recording, List Playout) GetDevices() + { + return _owner.GetDevicesViaFfi(); + } + + public void ApplyOutputPreference(IReadOnlyList ranked) + { + // No routing effect on desktop: output is selected per device, not by kind. + } + + public void SelectOutput(AudioDevice device) + { + if (!string.IsNullOrEmpty(device.Guid)) + _owner.SetPlayoutDevice(device.Guid); + else + _owner.SetPlayoutDevice(device.Index); + } + + public void ClearOutputOverride() + { + // No automatic policy to fall back to on desktop; the selected device stays. + } + + public event Action, IReadOnlyList> DevicesChanged + { + add { } + remove { } + } + + public void Dispose() + { + } + } + + /// + /// Placeholder backend for platforms whose routing implementation has not landed yet + /// (Android, iOS). Device snapshots still work through the FFI (a single placeholder + /// entry for the OS default input/output); the routing verbs throw or no-op as + /// documented on the public API. + /// + internal sealed class UnsupportedRouteController : IRouteController + { + private readonly PlatformAudio _owner; + private readonly string _platform; + + public UnsupportedRouteController(PlatformAudio owner, string platform) + { + _owner = owner; + _platform = platform; + } + + public (List Recording, List Playout) GetDevices() + { + return _owner.GetDevicesViaFfi(); + } + + public void ApplyOutputPreference(IReadOnlyList ranked) + { + // Stored by PlatformAudio; no routing effect until this platform's backend lands. + } + + public void SelectOutput(AudioDevice device) + { + throw new NotSupportedException( + $"SelectOutput is not implemented on {_platform} yet"); + } + + public void ClearOutputOverride() + { + // No override can exist on this platform: SelectOutput throws. + } + + public event Action, IReadOnlyList> DevicesChanged + { + add { } + remove { } + } + + public void Dispose() + { + } + } +} diff --git a/Runtime/Scripts/Audio/RouteController.cs.meta b/Runtime/Scripts/Audio/RouteController.cs.meta new file mode 100644 index 00000000..82c5747b --- /dev/null +++ b/Runtime/Scripts/Audio/RouteController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a99955b361ca4e5aa765a7e6dfc9e73 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/PlayMode/PlatformAudioTests.cs b/Tests/PlayMode/PlatformAudioTests.cs index 28b0ed7b..10b34752 100644 --- a/Tests/PlayMode/PlatformAudioTests.cs +++ b/Tests/PlayMode/PlatformAudioTests.cs @@ -102,6 +102,126 @@ public IEnumerator SetRecordingDeviceByIndex_OutOfRange_Throws() yield break; } + [UnityTest] + public IEnumerator OutputPreference_DefaultsAndRoundtrips() + { + using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + + // Documented default ranking. + CollectionAssert.AreEqual( + new[] + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + }, + platformAudio.OutputPreference); + + // Set/get roundtrip preserves order and content. + var ranked = new[] { AudioOutputKind.Usb, AudioOutputKind.Speaker, AudioOutputKind.Bluetooth }; + platformAudio.OutputPreference = ranked; + CollectionAssert.AreEqual(ranked, platformAudio.OutputPreference); + + yield break; + } + + [UnityTest] + public IEnumerator OutputPreference_RejectsInvalidLists() + { + using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + + Assert.Throws(() => platformAudio.OutputPreference = null); + Assert.Throws(() => + platformAudio.OutputPreference = new[] { AudioOutputKind.Unknown }); + Assert.Throws(() => + platformAudio.OutputPreference = new[] { AudioOutputKind.Speaker, AudioOutputKind.Speaker }); + + // A rejected assignment leaves the stored preference untouched. + CollectionAssert.AreEqual( + new[] + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + }, + platformAudio.OutputPreference); + + yield break; + } + + [UnityTest] + public IEnumerator SpeakerPreference_BoolAndListOrderAreOneState() + { + using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + + // Default ranking has Speaker ahead of Earpiece. + Assert.IsTrue(platformAudio.IsSpeakerOutputPreferred); + + // Setting the bool rewrites the Speaker/Earpiece order inside the list. + platformAudio.IsSpeakerOutputPreferred = false; + CollectionAssert.AreEqual( + new[] + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Earpiece, + AudioOutputKind.Speaker, + }, + platformAudio.OutputPreference); + Assert.IsFalse(platformAudio.IsSpeakerOutputPreferred); + + // Setting the list order flips the bool back — the list is the source of truth. + platformAudio.OutputPreference = new[] + { + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + AudioOutputKind.Bluetooth, + }; + Assert.IsTrue(platformAudio.IsSpeakerOutputPreferred); + + // A missing kind is inserted next to the present one so the value round-trips. + platformAudio.OutputPreference = new[] { AudioOutputKind.Bluetooth, AudioOutputKind.Speaker }; + platformAudio.IsSpeakerOutputPreferred = false; + CollectionAssert.AreEqual( + new[] { AudioOutputKind.Bluetooth, AudioOutputKind.Earpiece, AudioOutputKind.Speaker }, + platformAudio.OutputPreference); + Assert.IsFalse(platformAudio.IsSpeakerOutputPreferred); + + yield break; + } + + [UnityTest] + public IEnumerator SelectOutput_BogusDevice_Throws() + { + using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + + var bogus = new AudioDevice { Index = 9999, Name = "not-a-device", Guid = "no-such-guid" }; + Assert.Throws(() => platformAudio.SelectOutput(bogus)); + + // Clearing is always safe, whether or not an override exists. + Assert.DoesNotThrow(() => platformAudio.ClearOutputOverride()); + + yield break; + } + + [UnityTest] + public IEnumerator DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose() + { + var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + + Action, IReadOnlyList> handler = (playout, recording) => { }; + platformAudio.DevicesChanged += handler; + platformAudio.Dispose(); + + Assert.DoesNotThrow(() => platformAudio.DevicesChanged -= handler); + Assert.DoesNotThrow(() => platformAudio.DevicesChanged += handler); + Assert.DoesNotThrow(() => platformAudio.Dispose()); + + yield break; + } + [UnityTest] public IEnumerator StartThenStopRecording_DoesNotThrow() { From bf9b34856cf28051d0cd7bb4b84a16cf9ff1661c Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:18:17 +0200 Subject: [PATCH 13/35] Add Android route manager backend in C# (PAR-020) Implement the PAR-019 routing seam on Android as an SDK-owned backend, hardened from the device-validated sample hotfix (PR #364): - AndroidRouteController (API 31+) pins the communication device to the sticky SelectOutput override while its device is available, else the highest-ranked available kind per OutputPreference; kinds missing from the ranking are never auto-selected (pin released, OS default applies) - session-scoped MODE_IN_COMMUNICATION with save/restore of the prior mode; optional audio-focus request (internal, default off) - change detection via OnCommunicationDeviceChangedListener plus a 1.5 s poll thread for the trace-verified transitions that fire no OS event (device added while pinned; BT headset leaving the available list ~10 s after the route already fell back); no-op re-pin guard stops the feedback loop from our own setCommunicationDevice - GetDevices playout list and DevicesChanged now report real Android communication devices with Kind and IsSelected; recording stays the FFI default-input placeholder - StartRecording re-asserts the policy: since Android 13 the mode request is only honored while voice-communication capture is active - pre-API-31 stays a documented unsupported placeholder, matching the hotfix gate; platform notes on the public API updated accordingly Co-Authored-By: Claude Fable 5 --- .../Scripts/Audio/AndroidRouteController.cs | 582 ++++++++++++++++++ .../Audio/AndroidRouteController.cs.meta | 11 + Runtime/Scripts/Audio/PlatformAudio.cs | 86 ++- Runtime/Scripts/Audio/RouteController.cs | 11 +- 4 files changed, 655 insertions(+), 35 deletions(-) create mode 100644 Runtime/Scripts/Audio/AndroidRouteController.cs create mode 100644 Runtime/Scripts/Audio/AndroidRouteController.cs.meta diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs new file mode 100644 index 00000000..20914d0f --- /dev/null +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -0,0 +1,582 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using LiveKit.Internal; +using UnityEngine; + +namespace LiveKit +{ + /// + /// Android routing backend for , built on the + /// communication-device APIs introduced in Android 12 (API 31): + /// AudioManager.getAvailableCommunicationDevices / + /// setCommunicationDevice / clearCommunicationDevice. + /// + /// The controller owns the voice-communication audio session for its whole lifetime + /// (construction to ): it enters MODE_IN_COMMUNICATION + /// (saving and restoring the prior mode) and keeps the output route pinned to the + /// best device — the sticky override while its device is + /// still available, otherwise the highest-ranked available kind per the current + /// . Owning the mode is what makes the + /// pin authoritative: without it the platform periodically reasserts its own default + /// route (observed on Pixel 8a: Telecom flipped playout back to the earpiece every + /// ~6 s after a Bluetooth session ended). Note that since Android 13 the mode request + /// is only honored while the app has active voice-communication capture, so + /// re-asserts the policy when capture + /// (re)starts. + /// + /// Route changes are detected two ways, both required (device-verified in the + /// sample hotfix this backend is hardened from, PR #364): + /// - OnCommunicationDeviceChangedListener — fires when the OS changes or + /// clears the pin (e.g. the pinned device disconnected). + /// - A poll thread (every 1.5 s) — covers transitions that fire no event: a device + /// added while a pin is active, and the trace-verified teardown where a powered-off + /// Bluetooth headset stays in the available list up to ~10 s after the route + /// already fell back to the earpiece, then leaves the list without another + /// communication-device change. + /// + /// Threading: re-evaluation runs on whichever thread triggered it (Unity main, + /// the Android main executor, or the poll thread — all JVM-attached) behind one + /// lock. may therefore be raised from any of them; + /// marshals it to the Unity main thread. + /// + internal sealed class AndroidRouteController : IRouteController + { + // android.media.AudioManager / AudioAttributes constants. + private const int ModeInCommunication = 3; // AudioManager.MODE_IN_COMMUNICATION + private const int AudioFocusGain = 1; // AudioManager.AUDIOFOCUS_GAIN + private const int AudioFocusRequestGranted = 1; // AudioManager.AUDIOFOCUS_REQUEST_GRANTED + private const int UsageVoiceCommunication = 2; // AudioAttributes.USAGE_VOICE_COMMUNICATION + private const int ContentTypeSpeech = 1; // AudioAttributes.CONTENT_TYPE_SPEECH + + private const int MinSupportedApiLevel = 31; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1.5); + + private readonly PlatformAudio _owner; + private readonly object _gate = new object(); + private readonly ManualResetEventSlim _stopPoll = new ManualResetEventSlim(false); + private readonly List _recordingSnapshot; + private readonly Thread _pollThread; + + private List _ranked; + private int _stickyDeviceId = -1; + private int _pinnedDeviceId = -1; + private int _savedAudioMode; + private CommunicationDeviceListener _listener; + private AndroidJavaObject _audioFocusRequest; + private bool _audioFocusEnabled; + private List<(int Id, AudioOutputKind Kind, bool IsSelected)> _lastSignature; + private bool _disposed; + + public event Action, IReadOnlyList> DevicesChanged; + + /// + /// Creates the Android backend, or an on + /// Android versions below 12 (API 31), which lack the communication-device APIs + /// this backend is built on. On those versions the routing verbs are documented + /// no-ops/throws, matching the gate the sample hotfix carried. + /// + internal static IRouteController Create(PlatformAudio owner, IReadOnlyList initialPreference) + { + int sdkInt; + try + { + sdkInt = AndroidSdkInt(); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to read Build.VERSION.SDK_INT, routing disabled: {e.Message}"); + return new UnsupportedRouteController(owner, "this Android device"); + } + + if (sdkInt < MinSupportedApiLevel) + return new UnsupportedRouteController(owner, $"Android API {sdkInt} (routing requires API {MinSupportedApiLevel})"); + + return new AndroidRouteController(owner, initialPreference); + } + + private AndroidRouteController(PlatformAudio owner, IReadOnlyList initialPreference) + { + _owner = owner; + _ranked = new List(initialPreference); + + // The FFI exposes a single placeholder entry for the OS default input on + // Android; input routing follows the communication device, so this list is + // static and can back every DevicesChanged payload. Fetched before any + // session state is touched so a failure here has no side effects. + _recordingSnapshot = owner.GetDevicesViaFfi().Recording; + + EnterCommunicationMode(); + RegisterListener(); + Reevaluate(); + + _pollThread = new Thread(PollLoop) + { + IsBackground = true, + Name = "LiveKitAndroidRoutePoll", + }; + _pollThread.Start(); + } + + public (List Recording, List Playout) GetDevices() + { + var recording = _owner.GetDevicesViaFfi().Recording; + var playout = new List(); + try + { + using var audioManager = GetAudioManager(); + using var current = audioManager.Call("getCommunicationDevice"); + var currentId = current != null ? current.Call("getId") : -1; + + using var available = audioManager.Call("getAvailableCommunicationDevices"); + var count = available.Call("size"); + for (var i = 0; i < count; i++) + { + using var device = available.Call("get", i); + playout.Add(ToAudioDevice(device, (uint)i, currentId)); + } + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: device enumeration failed: {e.Message}"); + } + return (recording, playout); + } + + public void ApplyOutputPreference(IReadOnlyList ranked) + { + lock (_gate) + { + _ranked = new List(ranked); + } + Reevaluate(); + } + + public void SelectOutput(AudioDevice device) + { + if (string.IsNullOrEmpty(device.Guid) + || !int.TryParse(device.Guid, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) + throw new ArgumentException( + $"Device '{device.Name}' does not carry an Android device id; " + + "pass an entry from GetDevices().Playout", nameof(device)); + + lock (_gate) + { + _stickyDeviceId = id; + } + Reevaluate(); + } + + public void ClearOutputOverride() + { + lock (_gate) + { + if (_stickyDeviceId == -1) + return; + _stickyDeviceId = -1; + } + Reevaluate(); + } + + /// + /// Optional audio-focus request (AUDIOFOCUS_GAIN with voice-communication + /// attributes) held while enabled. Off by default. Not exposed on the public + /// API surface (PAR-019 defines it once); flip it here when embedding scenarios + /// need focus, until a supported knob exists. + /// + internal bool AudioFocusEnabled + { + get + { + lock (_gate) return _audioFocusEnabled; + } + set + { + lock (_gate) + { + if (_disposed || _audioFocusEnabled == value) + return; + _audioFocusEnabled = value; + if (value) + RequestAudioFocus(); + else + AbandonAudioFocus(); + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + } + + // Stop the poll first so no re-evaluation runs concurrently with teardown. + _stopPoll.Set(); + if (_pollThread.Join(TimeSpan.FromSeconds(3))) + _stopPoll.Dispose(); + else + Utils.Warning("AndroidRouteController: poll thread did not stop in time"); + + // Unregister BEFORE clearing the pin: clearCommunicationDevice fires the + // change event, and a still-registered listener would immediately re-pin. + UnregisterListener(); + + lock (_gate) + { + AbandonAudioFocus(); + } + + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("clearCommunicationDevice"); + audioManager.Call("setMode", _savedAudioMode); + Utils.Debug($"AndroidRouteController: route cleared, audio mode restored ({_savedAudioMode})"); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to restore audio session: {e.Message}"); + } + } + + /// + /// Single policy pass: picks the target device (sticky override while its device + /// is still available — dropped for good once it disappears — else the best + /// available kind by rank), pins it when it differs from the active route, and + /// raises when the observable list (ids, kinds, + /// selection) changed since the last pass. Re-pinning is skipped when the target + /// is already active: our own setCommunicationDevice fires the change listener, + /// and that no-op check is what stops the feedback loop. When nothing sticky or + /// ranked is available, an existing pin is released so the OS default applies; + /// kinds missing from the ranking are never auto-selected. + /// + private void Reevaluate() + { + List playout = null; + lock (_gate) + { + if (_disposed) + return; + try + { + using var audioManager = GetAudioManager(); + using var current = audioManager.Call("getCommunicationDevice"); + var currentId = current != null ? current.Call("getId") : -1; + + using var available = audioManager.Call("getAvailableCommunicationDevices"); + var count = available.Call("size"); + var devices = new List<(AndroidJavaObject Device, int Id, AudioOutputKind Kind)>(count); + try + { + for (var i = 0; i < count; i++) + { + var device = available.Call("get", i); + devices.Add((device, device.Call("getId"), KindFromDeviceType(device.Call("getType")))); + } + + var targetIndex = -1; + if (_stickyDeviceId != -1) + { + targetIndex = devices.FindIndex(d => d.Id == _stickyDeviceId); + if (targetIndex < 0) + { + Utils.Debug("AndroidRouteController: sticky output device disappeared; reverting to automatic policy"); + _stickyDeviceId = -1; + } + } + + if (targetIndex < 0) + { + var bestRank = int.MaxValue; + for (var i = 0; i < devices.Count; i++) + { + var rank = _ranked.IndexOf(devices[i].Kind); + if (rank >= 0 && rank < bestRank) + { + bestRank = rank; + targetIndex = i; + } + } + } + + int selectedId; + if (targetIndex >= 0) + { + var target = devices[targetIndex]; + if (target.Id != currentId) + { + var ok = audioManager.Call("setCommunicationDevice", target.Device); + Utils.Debug($"AndroidRouteController: setCommunicationDevice(kind={target.Kind}) -> {ok}"); + if (ok) + _pinnedDeviceId = target.Id; + selectedId = ok ? target.Id : currentId; + } + else + { + selectedId = currentId; + } + } + else + { + if (_pinnedDeviceId != -1) + { + audioManager.Call("clearCommunicationDevice"); + _pinnedDeviceId = -1; + Utils.Debug("AndroidRouteController: no ranked device available; cleared pin, OS default applies"); + using var fallback = audioManager.Call("getCommunicationDevice"); + selectedId = fallback != null ? fallback.Call("getId") : -1; + } + else + { + selectedId = currentId; + } + } + + var signature = new List<(int Id, AudioOutputKind Kind, bool IsSelected)>(devices.Count); + foreach (var d in devices) + signature.Add((d.Id, d.Kind, d.Id == selectedId)); + + if (SignatureChanged(signature)) + { + _lastSignature = signature; + playout = new List(devices.Count); + for (var i = 0; i < devices.Count; i++) + playout.Add(ToAudioDevice(devices[i].Device, (uint)i, selectedId)); + } + } + finally + { + foreach (var d in devices) + d.Device.Dispose(); + } + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: route evaluation failed: {e.Message}"); + } + } + + // Raised outside the lock; PlatformAudio marshals to the Unity main thread. + if (playout != null) + DevicesChanged?.Invoke(playout, new List(_recordingSnapshot)); + } + + private bool SignatureChanged(List<(int Id, AudioOutputKind Kind, bool IsSelected)> signature) + { + if (_lastSignature == null || _lastSignature.Count != signature.Count) + return true; + for (var i = 0; i < signature.Count; i++) + { + if (!_lastSignature[i].Equals(signature[i])) + return true; + } + return false; + } + + private void PollLoop() + { + if (AndroidJNI.AttachCurrentThread() != 0) + { + Utils.Warning("AndroidRouteController: failed to attach poll thread to the JVM; poll disabled, only OS events will re-route"); + return; + } + try + { + while (!_stopPoll.Wait(PollInterval)) + Reevaluate(); + } + finally + { + AndroidJNI.DetachCurrentThread(); + } + } + + private void EnterCommunicationMode() + { + try + { + using var audioManager = GetAudioManager(); + _savedAudioMode = audioManager.Call("getMode"); + audioManager.Call("setMode", ModeInCommunication); + Utils.Debug($"AndroidRouteController: audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})"); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to enter communication mode: {e.Message}"); + } + } + + private void RegisterListener() + { + try + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + using var audioManager = activity.Call("getSystemService", "audio"); + using var executor = activity.Call("getMainExecutor"); + + _listener = new CommunicationDeviceListener(this); + audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _listener); + } + catch (Exception e) + { + _listener = null; + Utils.Warning($"AndroidRouteController: failed to register device listener, falling back to polling only: {e.Message}"); + } + } + + private void UnregisterListener() + { + if (_listener == null) + return; + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("removeOnCommunicationDeviceChangedListener", _listener); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to unregister device listener: {e.Message}"); + } + _listener = null; + } + + // Both focus methods are called under _gate. + private void RequestAudioFocus() + { + try + { + using var attributesBuilder = new AndroidJavaObject("android.media.AudioAttributes$Builder"); + using var withUsage = attributesBuilder.Call("setUsage", UsageVoiceCommunication); + using var withContentType = withUsage.Call("setContentType", ContentTypeSpeech); + using var attributes = withContentType.Call("build"); + using var focusBuilder = new AndroidJavaObject("android.media.AudioFocusRequest$Builder", AudioFocusGain); + using var withAttributes = focusBuilder.Call("setAudioAttributes", attributes); + _audioFocusRequest = withAttributes.Call("build"); + + using var audioManager = GetAudioManager(); + var result = audioManager.Call("requestAudioFocus", _audioFocusRequest); + Utils.Debug($"AndroidRouteController: requestAudioFocus -> {(result == AudioFocusRequestGranted ? "granted" : result.ToString())}"); + } + catch (Exception e) + { + _audioFocusRequest?.Dispose(); + _audioFocusRequest = null; + Utils.Warning($"AndroidRouteController: audio focus request failed: {e.Message}"); + } + } + + private void AbandonAudioFocus() + { + if (_audioFocusRequest == null) + return; + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("abandonAudioFocusRequest", _audioFocusRequest); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to abandon audio focus: {e.Message}"); + } + _audioFocusRequest.Dispose(); + _audioFocusRequest = null; + } + + private void OnCommunicationDeviceChangedFromJava() + { + try + { + Reevaluate(); + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: listener re-evaluation failed: {e.Message}"); + } + } + + private static AudioDevice ToAudioDevice(AndroidJavaObject device, uint index, int selectedId) + { + var id = device.Call("getId"); + using var productName = device.Call("getProductName"); + return new AudioDevice + { + Index = index, + Name = productName?.Call("toString") ?? string.Empty, + Guid = id.ToString(CultureInfo.InvariantCulture), + Kind = KindFromDeviceType(device.Call("getType")), + IsSelected = id == selectedId, + }; + } + + // AudioDeviceInfo.TYPE_* to AudioOutputKind, mirroring the planned FFI mapping. + private static AudioOutputKind KindFromDeviceType(int deviceType) + { + switch (deviceType) + { + case 1: // TYPE_BUILTIN_EARPIECE + return AudioOutputKind.Earpiece; + case 2: // TYPE_BUILTIN_SPEAKER + return AudioOutputKind.Speaker; + case 3: // TYPE_WIRED_HEADSET + case 4: // TYPE_WIRED_HEADPHONES + return AudioOutputKind.WiredHeadset; + case 7: // TYPE_BLUETOOTH_SCO + case 26: // TYPE_BLE_HEADSET + case 27: // TYPE_BLE_SPEAKER + return AudioOutputKind.Bluetooth; + case 22: // TYPE_USB_HEADSET + return AudioOutputKind.Usb; + case 23: // TYPE_HEARING_AID + return AudioOutputKind.HearingAid; + default: + return AudioOutputKind.Unknown; + } + } + + private static int AndroidSdkInt() + { + using var version = new AndroidJavaClass("android.os.Build$VERSION"); + return version.GetStatic("SDK_INT"); + } + + // Caller owns the returned object (wrap it in `using var`). + private static AndroidJavaObject GetAudioManager() + { + using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using var activity = unityPlayer.GetStatic("currentActivity"); + return activity.Call("getSystemService", "audio"); + } + + // C#-side implementation of the Java callback interface. AndroidJavaProxy can + // only implement interfaces, which is why this listens for communication-device + // changes rather than subclassing android.media.AudioDeviceCallback (an abstract + // class); list add/remove transitions that fire no communication-device event + // are covered by the poll thread instead. + private sealed class CommunicationDeviceListener : AndroidJavaProxy + { + private readonly AndroidRouteController _controller; + + public CommunicationDeviceListener(AndroidRouteController controller) + : base("android.media.AudioManager$OnCommunicationDeviceChangedListener") + { + _controller = controller; + } + + // Invoked by Android on the activity's main executor — a JVM-attached + // thread, but not the Unity main thread. + public void onCommunicationDeviceChanged(AndroidJavaObject device) + { + device?.Dispose(); + _controller.OnCommunicationDeviceChangedFromJava(); + } + } + } +} +#endif diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs.meta b/Runtime/Scripts/Audio/AndroidRouteController.cs.meta new file mode 100644 index 00000000..1f1eb67b --- /dev/null +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f96269970c2ac4b4ea77f794848cafae +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 414ef160..db77860b 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -76,14 +76,16 @@ public struct AudioDevice /// public string Guid; /// - /// The kind of output this device represents. - /// where the platform does not report a type — currently all devices: no routing - /// backend classifies devices yet. + /// The kind of output this device represents. Classified by the Android routing + /// backend (Android 12/API 31 and newer); + /// where the platform does not report a type or no backend classifies devices + /// yet (desktop, iOS, older Android). /// public AudioOutputKind Kind; /// - /// Whether this device is the active output route. Only meaningful once a platform - /// routing backend reports selection state — currently always false. + /// Whether this device is the active output route. Reported by the Android + /// routing backend (Android 12/API 31 and newer); always false where no backend + /// reports selection state yet (desktop, iOS, older Android). /// public bool IsSelected; } @@ -176,7 +178,7 @@ public PlatformAudio() private IRouteController CreateRouteController() { #if UNITY_ANDROID && !UNITY_EDITOR - return new UnsupportedRouteController(this, "Android"); + return AndroidRouteController.Create(this, _outputPreference); #elif UNITY_IOS && !UNITY_EDITOR return new UnsupportedRouteController(this, "iOS"); #else @@ -191,9 +193,15 @@ private IRouteController CreateRouteController() /// - Desktop (Windows/macOS/Linux): returns the full list of microphones and /// speakers reported by the OS. Devices can be selected with /// / . - /// - iOS and Android: returns a single placeholder entry at index 0 for each - /// list, representing the system's currently selected default input/output. - /// The OS owns audio routing on these platforms (AVAudioSession on iOS, + /// - Android 12 (API 31) and newer: the playout list contains the available + /// communication devices with and + /// set; entries can be routed to with + /// . The recording list stays a single placeholder + /// entry for the OS default input — input routing follows the selected + /// communication device. + /// - iOS and older Android: returns a single placeholder entry at index 0 for + /// each list, representing the system's currently selected default + /// input/output. The OS owns audio routing there (AVAudioSession on iOS, /// AudioManager on Android), so individual devices are not enumerated and /// selecting one is a no-op (see / /// ). @@ -202,8 +210,8 @@ private IRouteController CreateRouteController() /// A tuple containing: /// - Recording: List of available microphones (on iOS/Android, a single /// placeholder for the OS default input) - /// - Playout: List of available speakers/headphones (on iOS/Android, a single - /// placeholder for the OS default output) + /// - Playout: List of available speakers/headphones (on iOS and pre-API-31 + /// Android, a single placeholder for the OS default output) /// /// /// Thrown if device enumeration failed. @@ -268,11 +276,14 @@ private IRouteController CreateRouteController() /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority /// over the built-in outputs, so the Speaker/Earpiece relative order — i.e. /// — is the only part of the ranking with an - /// effect. On Android the full ranking applies. On desktop, output is selected + /// effect. On Android the full ranking applies: the backend routes to the + /// highest-ranked available kind on Android 12 (API 31) and newer, and kinds + /// missing from the list are never auto-selected (when nothing ranked is + /// available the OS default route applies). On desktop, output is selected /// per device ( / ) - /// and the ranking has no routing effect. The mobile routing backends are not - /// implemented yet in this version: on Android and iOS the value is currently - /// stored and round-trips, but has no routing effect either. + /// and the ranking has no routing effect. On older Android versions and on iOS + /// (routing backend not implemented yet in this version) the value is stored and + /// round-trips, but has no routing effect either. /// /// Thrown if set to null. /// @@ -318,11 +329,13 @@ public IReadOnlyList OutputPreference /// /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority /// over the built-in outputs, so this bool is the only part of the ranking with an - /// effect. On Android the full ranking applies. On desktop, output is selected - /// per device ( / ) - /// and the ranking has no routing effect. The mobile routing backends are not - /// implemented yet in this version: on Android and iOS the value is currently - /// stored and round-trips, but has no routing effect either. + /// effect. On Android the full ranking applies: the backend routes to the + /// highest-ranked available kind on Android 12 (API 31) and newer. On desktop, + /// output is selected per device ( / + /// ) and the ranking has no routing effect. + /// On older Android versions and on iOS (routing backend not implemented yet in + /// this version) the value is stored and round-trips, but has no routing effect + /// either. /// public bool IsSpeakerOutputPreferred { @@ -373,16 +386,18 @@ public bool IsSpeakerOutputPreferred /// when set, otherwise by index and name. /// /// Platform notes: on desktop this selects the device like - /// . On Android and iOS the routing backends - /// are not implemented yet in this version and this method throws - /// . + /// . On Android 12 (API 31) and newer the + /// device is pinned as the communication device; the override is dropped once the + /// device disappears from the playout list (automatic policy resumes). On older + /// Android versions and on iOS (routing backend not implemented yet in this + /// version) this method throws . /// /// A playout device from . /// /// Thrown if the device does not match any current playout device. /// /// - /// Thrown on Android and iOS, where no routing backend exists yet. + /// Thrown on iOS (no routing backend yet) and on Android below API 31. /// public void SelectOutput(AudioDevice device) { @@ -408,9 +423,10 @@ public void SelectOutput(AudioDevice device) /// policy applies again. /// /// Platform notes: on desktop there is no automatic policy to fall back to yet, so - /// clearing keeps the currently selected device (no-op). On Android and iOS no - /// override can exist yet ( throws), so this is a no-op - /// there as well. + /// clearing keeps the currently selected device (no-op). On Android 12 (API 31) + /// and newer the automatic policy re-routes immediately. On older Android + /// versions and on iOS no override can exist ( throws), + /// so this is a no-op there. /// public void ClearOutputOverride() { @@ -421,9 +437,12 @@ public void ClearOutputOverride() /// Raised when the set of available audio devices changes, with the current playout /// and recording device lists. Raised on the Unity main thread. /// - /// No implementation raises this event yet in this version: desktop hot-plug events - /// and the mobile routing backends that produce it are not implemented. Subscribing - /// and unsubscribing is safe at any time, including after . + /// Raised by the Android routing backend (Android 12/API 31 and newer) when the + /// available communication devices or the active route change; changes that fire + /// no OS event are detected by a poll with roughly 1.5 s of latency. Desktop + /// hot-plug events and the iOS backend are not implemented yet in this version, so + /// the event is never raised there. Subscribing and unsubscribing is safe at any + /// time, including after . /// public event Action, IReadOnlyList> DevicesChanged; @@ -590,6 +609,13 @@ public IEnumerator StartRecording() Utils.Debug("PlatformAudio: started recording"); + // Re-assert the routing policy now that capture is active. Since Android 13 + // the app's MODE_IN_COMMUNICATION request — and with it the + // communication-device pin — is only honored while the app has active + // voice-communication capture, so the platform may have moved the route + // while it was un-owned. No-op on the other backends. + _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly()); + // Ensures this method is always a valid iterator even when the PLATFORM_ANDROID // branch is compiled out (no `yield return` would otherwise be reachable on // non-Android builds, which is a compile error for IEnumerator-returning methods). diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs index 31d7861b..e54b65b5 100644 --- a/Runtime/Scripts/Audio/RouteController.cs +++ b/Runtime/Scripts/Audio/RouteController.cs @@ -87,10 +87,11 @@ public void Dispose() } /// - /// Placeholder backend for platforms whose routing implementation has not landed yet - /// (Android, iOS). Device snapshots still work through the FFI (a single placeholder - /// entry for the OS default input/output); the routing verbs throw or no-op as - /// documented on the public API. + /// Placeholder backend for platforms without a routing implementation: iOS (not + /// landed yet) and Android below API 31 (which lacks the communication-device APIs + /// the Android backend is built on). Device snapshots still work through the FFI (a + /// single placeholder entry for the OS default input/output); the routing verbs + /// throw or no-op as documented on the public API. /// internal sealed class UnsupportedRouteController : IRouteController { @@ -116,7 +117,7 @@ public void ApplyOutputPreference(IReadOnlyList ranked) public void SelectOutput(AudioDevice device) { throw new NotSupportedException( - $"SelectOutput is not implemented on {_platform} yet"); + $"SelectOutput is not supported on {_platform}"); } public void ClearOutputOverride() From 7ad6cfe5b1e2e36224720035dce65de632e78070 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:02:37 +0200 Subject: [PATCH 14/35] Add iOS session states, speaker preference, and route events (PAR-021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audio session config is now derived from a small state machine (idle / playout-only / recording) driven from C#, where PlatformAudio knows the recording state, plus a speaker-vs-earpiece preference expressed through the session mode (VideoChat/VoiceChat) — never overrideOutputAudioPort, so external devices always win. Non-recording states drop to mode Default + MixWithOthers to keep Unity audio unprocessed between calls; PlayAndRecord stays because the fork's ADM supports playout-only via an input-disabled VPIO unit but nothing guarantees VPIO under the Playback category. Every apply is mirrored into WebRTC's RTCAudioSessionConfiguration snapshot (reflected, no link-time dependency) so ADM-driven restarts re-apply the same config. Route changes are observed via AVAudioSessionRouteChangeNotification and forwarded to the new IosRouteController, which implements the PAR-019 seam: the playout list is the session's current output route with real Kind/IsSelected, DevicesChanged is raised on route changes (marshalled to the Unity main thread by PlatformAudio), OutputPreference reduces to the Speaker/Earpiece relative order, and SelectOutput throws the documented NotSupportedException pointing at AVRoutePickerView. Co-Authored-By: Claude Fable 5 --- Runtime/Plugins/iOS/LiveKitAudioSession.mm | 345 ++++++++++++++---- Runtime/Scripts/Audio/IosRouteController.cs | 216 +++++++++++ .../Scripts/Audio/IosRouteController.cs.meta | 11 + Runtime/Scripts/Audio/PlatformAudio.cs | 140 +++++-- Runtime/Scripts/Audio/RouteController.cs | 2 +- 5 files changed, 614 insertions(+), 100 deletions(-) create mode 100644 Runtime/Scripts/Audio/IosRouteController.cs create mode 100644 Runtime/Scripts/Audio/IosRouteController.cs.meta diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index e3907cba..c2e1e766 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -17,6 +17,9 @@ #import #import +#include +#include + // This plugin coordinates the single shared AVAudioSession with WebRTC's iOS // Audio Device Module (ADM). WebRTC ships an RTCAudioSession proxy that, left in // its default "automatic" mode, reconfigures the category/route and *deactivates* @@ -30,11 +33,14 @@ // * We hold exactly one permanent activation (setActive:YES). Because // RTCAudioSession ref-counts activation, WebRTC's per-call setActive:YES/NO // only cycles the count and never actually deactivates the hardware session. -// * We set the category once (PlayAndRecord + VideoChat mode). VideoChat routes -// to the loudspeaker by default (while still honoring connected wired/Bluetooth -// headphones), so WebRTC re-applying its own config keeps output on the speaker -// instead of the earpiece. We deliberately do NOT force the speaker via -// overrideOutputAudioPort, which would override plugged-in headphones. +// * The category/mode/options are derived from a session STATE machine driven +// from C# (PlatformAudio knows the call's recording state) plus a +// speaker-vs-earpiece preference (see the table below). Every apply is also +// mirrored into WebRTC's RTCAudioSessionConfiguration snapshot so the ADM +// re-applies the same config on its own restarts. +// * The speaker preference is expressed via the session MODE only (VideoChat +// routes to the loudspeaker by default, VoiceChat to the receiver), never via +// overrideOutputAudioPort, so connected wired/Bluetooth devices always win. // * The VPIO voice-processing unit (hardware AEC/AGC/NS) is gated by // isAudioEnabled. It defaults to YES so call audio works out of the box (the // unit still only initializes once a call actually has an audio track, so @@ -46,8 +52,28 @@ // with no retry -- and Unity/FMOD restarts *its* audio around the same moment, // reconfiguring the shared session (observed with Unity 6). Whoever loses that // race stays broken, so we observe foreground/interruption-end ourselves and, -// after Unity's restart has settled, re-assert our config and cycle -// isAudioEnabled to force a clean rebuild of the audio unit. +// after Unity's restart has settled, re-assert the current state's config and +// cycle isAudioEnabled to force a clean rebuild of the audio unit. +// * Route changes (headset plug/unplug, Bluetooth connect, mode switches) are +// observed via AVAudioSessionRouteChangeNotification and forwarded to C# +// through a registered callback so the SDK can raise its DevicesChanged event. +// +// Session state table (state is set from C# via LiveKit_SetSessionState): +// +// state category mode options +// 0 idle PlayAndRecord Default BT | A2DP | MixWithOthers +// | DefaultToSpeaker* +// 1 playout-only PlayAndRecord Default same as idle +// 2 recording PlayAndRecord VideoChat (speaker) / BT | A2DP +// VoiceChat (earpiece) +// +// *DefaultToSpeaker only while the speaker is preferred. In the recording state +// the speaker preference is carried by the mode alone. Idle and playout-only +// share a config: PlayAndRecord stays because the ADM initializes its VPIO unit +// with input disabled for playout-only (InitPlayOrRecord(false)) but nothing +// guarantees VPIO under the Playback category; mode Default + MixWithOthers is +// the music-friendliest config the ADM demonstrably supports. The states stay +// distinct so the mapping can diverge without touching the C# driver. // // RTCAudioSession lives inside the statically-linked liblivekit_ffi; we reach it // dynamically via NSClassFromString + a protocol-typed id so this file never @@ -68,6 +94,23 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category error:(NSError**)outError; @end +/// Minimal subset of WebRTC's RTCAudioSessionConfiguration (the snapshot the ADM +/// re-applies on its own restarts), messaged dynamically like RTCAudioSession. +@protocol LiveKitRTCAudioSessionConfiguration +@property(nonatomic, strong) NSString* category; +@property(nonatomic, assign) AVAudioSessionCategoryOptions categoryOptions; +@property(nonatomic, strong) NSString* mode; +@end + +/// Session states, mirroring PlatformAudio's driver in C#. Do not renumber. +enum { + kLiveKitSessionStateIdle = 0, + kLiveKitSessionStatePlayoutOnly = 1, + kLiveKitSessionStateRecording = 2, +}; + +typedef void (*LiveKitRouteChangeCallback)(void); + // Tracks whether *we* currently hold the one app-owned activation, so we add and // release it exactly once regardless of how many times configure/restore run. static BOOL s_liveKitHoldsActivation = NO; @@ -91,10 +134,27 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category // on foreground) into one delayed pass. static BOOL s_recoveryPending = NO; -static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions = - AVAudioSessionCategoryOptionDefaultToSpeaker | +// The state machine inputs (see the table above). The defaults match what a fresh +// PlatformAudio pushes right after construction, so the config applied by +// configure is already the one the C# driver expects. +static int s_sessionState = kLiveKitSessionStatePlayoutOnly; +static BOOL s_speakerPreferred = YES; + +// Invoked (on the main queue) whenever the audio route changes, so the C# side +// can re-query the route and raise DevicesChanged. +static LiveKitRouteChangeCallback s_routeChangeCallback = NULL; + +// AllowBluetooth was renamed AllowBluetoothHFP in the iOS 26 SDK; same guard the +// WebRTC fork uses. The speaker preference never rides on these options. +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 +static const AVAudioSessionCategoryOptions kLiveKitBluetoothOptions = + AVAudioSessionCategoryOptionAllowBluetoothHFP | + AVAudioSessionCategoryOptionAllowBluetoothA2DP; +#else +static const AVAudioSessionCategoryOptions kLiveKitBluetoothOptions = AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionAllowBluetoothA2DP; +#endif /// Returns WebRTC's shared RTCAudioSession if it's present in the linked binary, /// or nil if the class can't be found (in which case callers use AVAudioSession). @@ -109,7 +169,92 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category #pragma clang diagnostic pop } -/// Re-applies LiveKit's category/mode/options and reactivates the session, then +static NSString* LiveKit_DesiredMode() { + if (s_sessionState == kLiveKitSessionStateRecording) { + return s_speakerPreferred ? AVAudioSessionModeVideoChat + : AVAudioSessionModeVoiceChat; + } + return AVAudioSessionModeDefault; +} + +static AVAudioSessionCategoryOptions LiveKit_DesiredOptions() { + AVAudioSessionCategoryOptions options = kLiveKitBluetoothOptions; + if (s_sessionState != kLiveKitSessionStateRecording) { + options |= AVAudioSessionCategoryOptionMixWithOthers; + // Mode Default routes PlayAndRecord to the receiver; outside a call there + // is no mode that both prefers the speaker and leaves music processing + // alone, so here -- and only here -- the preference rides on an option. + if (s_speakerPreferred) { + options |= AVAudioSessionCategoryOptionDefaultToSpeaker; + } + } + return options; +} + +/// Mirrors our category/mode/options into WebRTC's RTCAudioSessionConfiguration +/// snapshot so the ADM re-applies the same config whenever it (re)configures the +/// session itself (audio unit init, interruption recovery). +static void LiveKit_MirrorWebRTCConfiguration(NSString* mode, + AVAudioSessionCategoryOptions options) { + Class cls = NSClassFromString(@"RTCAudioSessionConfiguration"); + if (!cls || ![cls respondsToSelector:@selector(webRTCConfiguration)] || + ![cls respondsToSelector:@selector(setWebRTCConfiguration:)]) { + return; + } +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + id config = + (id)[cls performSelector:@selector(webRTCConfiguration)]; + if (config == nil) { + return; + } + config.category = AVAudioSessionCategoryPlayAndRecord; + config.mode = mode; + config.categoryOptions = options; + [cls performSelector:@selector(setWebRTCConfiguration:) withObject:config]; +#pragma clang diagnostic pop +} + +/// Applies the config derived from (s_sessionState, s_speakerPreferred) to the +/// session and mirrors it into the WebRTC snapshot. Logs expected vs. actual so +/// device tests can see who won when something else reconfigures the session. +static void LiveKit_ApplySessionConfig(NSString* reason) { + NSString* mode = LiveKit_DesiredMode(); + AVAudioSessionCategoryOptions options = LiveKit_DesiredOptions(); + + id rtc = LiveKit_RTCSession(); + NSError* error = nil; + if (rtc != nil) { + [rtc lockForConfiguration]; + if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord + mode:mode + options:options + error:&error] || error) { + NSLog(@"LiveKit: failed to apply session config (%@): %@", + reason, error.localizedDescription); + } + [rtc unlockForConfiguration]; + } else { + AVAudioSession* session = [AVAudioSession sharedInstance]; + if (![session setCategory:AVAudioSessionCategoryPlayAndRecord + mode:mode + options:options + error:&error] || error) { + NSLog(@"LiveKit: failed to apply session config (%@): %@", + reason, error.localizedDescription); + } + } + + LiveKit_MirrorWebRTCConfiguration(mode, options); + + AVAudioSession* current = [AVAudioSession sharedInstance]; + NSLog(@"LiveKit: session config (%@): state=%d speakerPreferred=%d expected mode=%@ options=%lu" + " -> actual category=%@ mode=%@ options=%lu", + reason, s_sessionState, s_speakerPreferred, mode, (unsigned long)options, + current.category, current.mode, (unsigned long)current.categoryOptions); +} + +/// Re-applies the current state's config and reactivates the session, then /// cycles isAudioEnabled to force WebRTC to rebuild its VPIO audio unit. Runs on /// a delay so it lands after Unity/FMOD's own foreground audio restart (which is /// itself delayed and can reconfigure the shared session underneath WebRTC's @@ -131,31 +276,13 @@ static void LiveKit_ScheduleSessionRecovery() { NSLog(@"LiveKit: foreground recovery; session before re-assert: category=%@ mode=%@ options=%lu", session.category, session.mode, (unsigned long)session.categoryOptions); - id rtc = LiveKit_RTCSession(); - NSError* error = nil; - if (rtc != nil) { - [rtc lockForConfiguration]; - if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); - } - [rtc unlockForConfiguration]; - } else { - if (![session setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); - } - } + LiveKit_ApplySessionConfig(@"foreground recovery"); // Reactivate directly on AVAudioSession: the OS deactivated the hardware // session during the interruption, but RTCAudioSession's activation // ref-count still includes our held activation, so reactivating through // the proxy would double-count it. - error = nil; + NSError* error = nil; if (![session setActive:YES error:&error] || error) { NSLog(@"LiveKit: recovery failed to reactivate session: %@", error.localizedDescription); } @@ -164,6 +291,7 @@ static void LiveKit_ScheduleSessionRecovery() { // session (WebRTC's own foreground restart may have failed, or been undone // by Unity's). Harmless when no call audio is active: with playout and // recording uninitialized WebRTC ignores the change. + id rtc = LiveKit_RTCSession(); if (rtc != nil && s_audioDesired) { rtc.isAudioEnabled = NO; rtc.isAudioEnabled = YES; @@ -174,9 +302,9 @@ static void LiveKit_ScheduleSessionRecovery() { }); } -/// Registers app-lifetime observers that trigger session recovery when the app -/// returns to the foreground or an audio interruption ends. Registered once on -/// first configure; the handlers no-op while LiveKit is not configured. +/// Registers app-lifetime observers for foreground/interruption recovery and for +/// route-change forwarding. Registered once on first configure; the handlers +/// no-op while LiveKit is not configured. static void LiveKit_RegisterLifecycleObserversIfNeeded() { static BOOL s_observersRegistered = NO; if (s_observersRegistered) { @@ -200,6 +328,27 @@ static void LiveKit_RegisterLifecycleObserversIfNeeded() { LiveKit_ScheduleSessionRecovery(); } }]; + [center addObserverForName:AVAudioSessionRouteChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + if (!s_liveKitConfigured) { + return; + } + NSNumber* reason = note.userInfo[AVAudioSessionRouteChangeReasonKey]; + NSMutableArray* outputs = [NSMutableArray array]; + for (AVAudioSessionPortDescription* port in + [AVAudioSession sharedInstance].currentRoute.outputs) { + [outputs addObject:[NSString stringWithFormat:@"%@ (%@)", port.portName, port.portType]]; + } + NSLog(@"LiveKit: route changed (reason=%lu) outputs=%@", + (unsigned long)reason.unsignedIntegerValue, + [outputs componentsJoinedByString:@", "]); + LiveKitRouteChangeCallback callback = s_routeChangeCallback; + if (callback != NULL) { + callback(); + } + }]; } /// Captures the current audio session category/mode/options exactly once, before @@ -219,15 +368,31 @@ static void LiveKit_CacheSessionStateIfNeeded() { s_cachedCategory, s_cachedMode, (unsigned long)s_cachedCategoryOptions); } +/// Maps an AVAudioSessionPort type to the C# AudioOutputKind numbering +/// (Unknown=0, Earpiece=1, Speaker=2, WiredHeadset=3, Bluetooth=4, Usb=5, +/// HearingAid=6). Do not renumber. AVAudioSession has no dedicated hearing-aid +/// port type, so 6 is never produced here; AirPlay/HDMI/CarAudio and other +/// unroutable-by-us ports map to Unknown. +static int LiveKit_OutputKindForPortType(NSString* portType) { + if ([portType isEqualToString:AVAudioSessionPortBuiltInReceiver]) return 1; + if ([portType isEqualToString:AVAudioSessionPortBuiltInSpeaker]) return 2; + if ([portType isEqualToString:AVAudioSessionPortHeadphones]) return 3; + if ([portType isEqualToString:AVAudioSessionPortBluetoothA2DP] || + [portType isEqualToString:AVAudioSessionPortBluetoothHFP] || + [portType isEqualToString:AVAudioSessionPortBluetoothLE]) return 4; + if ([portType isEqualToString:AVAudioSessionPortUSBAudio]) return 5; + return 0; +} + extern "C" { /// Configures the iOS audio session for VoIP/WebRTC use and takes app ownership /// of the shared AVAudioSession. /// -/// This sets AVAudioSessionCategoryPlayAndRecord with VideoChat mode (which routes -/// to the loudspeaker by default and enables the VPIO Voice Processing IO unit for -/// hardware AEC/AGC/NS), puts RTCAudioSession into manual mode, and holds a single -/// permanent activation so WebRTC never deactivates the session on its own. +/// This applies the config for the current session state (playout-only for a +/// fresh PlatformAudio; see the state table at the top of this file), puts +/// RTCAudioSession into manual mode, and holds a single permanent activation so +/// WebRTC never deactivates the session on its own. /// /// Call this before creating PlatformAudio. Call audio is enabled by default, so /// no further call is required for it to work; use LiveKit_SetAudioEnabled(false) @@ -242,53 +407,42 @@ void LiveKit_ConfigureAudioSessionForVoIP() { id rtc = LiveKit_RTCSession(); + // Manual mode: WebRTC won't activate/deactivate the session on its own, and + // won't initialize the VPIO unit until we grant permission via isAudioEnabled + // (set below). This is what lets us own activation and gate the unit. Set + // before the first apply so the ADM never races the initial configuration. + if (rtc != nil) { + rtc.useManualAudio = YES; + } + + LiveKit_ApplySessionConfig(@"configure"); + if (rtc == nil) { - // RTCAudioSession unavailable: configure AVAudioSession directly (legacy). + // RTCAudioSession unavailable: activate AVAudioSession directly (legacy). AVAudioSession* session = [AVAudioSession sharedInstance]; NSError* error = nil; - if (![session setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: Failed to configure audio session: %@", error.localizedDescription); - return; - } if (![session setActive:YES error:&error] || error) { NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); return; } - NSLog(@"LiveKit: Audio session configured (AVAudioSession fallback, VideoChat)"); + NSLog(@"LiveKit: Audio session configured (AVAudioSession fallback)"); return; } - // Manual mode: WebRTC won't activate/deactivate the session on its own, and - // won't initialize the VPIO unit until we grant permission via isAudioEnabled - // (set below). This is what lets us own activation and gate the unit. - rtc.useManualAudio = YES; - - [rtc lockForConfiguration]; - NSError* error = nil; - if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: Failed to set audio category: %@", error.localizedDescription); - } - // Hold exactly one app-owned activation. RTCAudioSession ref-counts activation, // so WebRTC's balanced setActive:YES/NO during a call never drops the real // session below active while we hold this. if (!s_liveKitHoldsActivation) { - error = nil; + [rtc lockForConfiguration]; + NSError* error = nil; if ([rtc setActive:YES error:&error] && !error) { s_liveKitHoldsActivation = YES; } else { NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); } + [rtc unlockForConfiguration]; } - [rtc unlockForConfiguration]; - // Grant WebRTC permission to initialize its audio unit by default so call audio // works without an explicit LiveKit_SetAudioEnabled(true). The unit is only // actually created once a call has an audio track, so pre-call audio is @@ -296,7 +450,7 @@ void LiveKit_ConfigureAudioSessionForVoIP() { // LiveKit_SetAudioEnabled(false). rtc.isAudioEnabled = YES; - NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VideoChat, manual mode, activationCount=%d)", + NSLog(@"LiveKit: Audio session configured for VoIP (manual mode, activationCount=%d)", rtc.activationCount); } @@ -317,6 +471,65 @@ void LiveKit_SetAudioEnabled(bool enabled) { NSLog(@"LiveKit: isAudioEnabled=%@ (activationCount=%d)", enabled ? @"YES" : @"NO", rtc.activationCount); } +/// Sets whether the loudspeaker is preferred over the earpiece for the built-in +/// outputs and live-applies the resulting config (see the state table). External +/// devices (wired, Bluetooth) always take priority over both; this only decides +/// where audio goes when no external device is connected. +void LiveKit_SetSpeakerPreferred(bool preferred) { + BOOL value = preferred ? YES : NO; + if (s_speakerPreferred == value) { + return; + } + s_speakerPreferred = value; + if (s_liveKitConfigured) { + LiveKit_ApplySessionConfig(@"speaker preference"); + } +} + +/// Sets the session state (0 idle, 1 playout-only, 2 recording; see the state +/// table) and live-applies the resulting config. Driven from C#: PlatformAudio +/// knows whether recording is active and whether call audio is wanted. +void LiveKit_SetSessionState(int state) { + if (state < kLiveKitSessionStateIdle || state > kLiveKitSessionStateRecording) { + NSLog(@"LiveKit: ignoring unknown session state %d", state); + return; + } + if (s_sessionState == state) { + return; + } + s_sessionState = state; + if (s_liveKitConfigured) { + LiveKit_ApplySessionConfig(@"session state"); + } +} + +/// Registers (or clears, with NULL) the callback invoked on the main queue +/// whenever the audio route changes. The callback carries no payload; the C# +/// side re-queries LiveKit_GetCurrentOutputRoutes. +void LiveKit_SetRouteChangeCallback(LiveKitRouteChangeCallback callback) { + s_routeChangeCallback = callback; +} + +/// Returns the current output route as newline-separated "kind\tname\tuid" +/// entries (kind per LiveKit_OutputKindForPortType). The caller must release the +/// returned buffer with LiveKit_FreeRouteString. +char* LiveKit_GetCurrentOutputRoutes() { + NSMutableString* result = [NSMutableString string]; + for (AVAudioSessionPortDescription* port in + [AVAudioSession sharedInstance].currentRoute.outputs) { + [result appendFormat:@"%d\t%@\t%@\n", + LiveKit_OutputKindForPortType(port.portType), + port.portName ?: @"", + port.UID ?: @""]; + } + return strdup(result.UTF8String); +} + +/// Frees a buffer returned by LiveKit_GetCurrentOutputRoutes. +void LiveKit_FreeRouteString(char* str) { + free(str); +} + /// Restores the audio session Unity had before LiveKit touched it (or the ambient /// category if LiveKit never configured it), relinquishes the app-owned activation /// and manual mode, and reactivates the session so Unity audio output resumes. @@ -325,6 +538,10 @@ void LiveKit_RestoreDefaultAudioSession() { // Stand down the foreground-recovery observers before touching the session. s_liveKitConfigured = NO; s_audioDesired = NO; + // Reset the state machine to the defaults a fresh PlatformAudio expects, so a + // later reconfigure starts from the same config it will be driven to. + s_sessionState = kLiveKitSessionStatePlayoutOnly; + s_speakerPreferred = YES; id rtc = LiveKit_RTCSession(); diff --git a/Runtime/Scripts/Audio/IosRouteController.cs b/Runtime/Scripts/Audio/IosRouteController.cs new file mode 100644 index 00000000..7dc5ef08 --- /dev/null +++ b/Runtime/Scripts/Audio/IosRouteController.cs @@ -0,0 +1,216 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using LiveKit.Internal; + +namespace LiveKit +{ + /// + /// iOS routing backend over the LiveKitAudioSession.mm plugin. The OS owns output + /// route selection on iOS, so this backend does not pick devices: it reduces + /// to the speaker-vs-earpiece relative + /// order (applied as the audio session mode by the plugin; external devices always + /// take priority over both built-ins), reports the session's current output route + /// as the playout device list, and raises from the + /// plugin's route-change observation. throws: apps that + /// want explicit device picking should present the system route picker + /// (AVRoutePickerView). + /// + /// All plugin P/Invoke for route observation stays inside this class; the session + /// state machine itself is driven by (which knows the + /// recording state) through . + /// + internal sealed class IosRouteController : IRouteController + { + private delegate void RouteChangeDelegate(); + + [DllImport("__Internal")] + private static extern void LiveKit_SetRouteChangeCallback(RouteChangeDelegate callback); + + [DllImport("__Internal")] + private static extern void LiveKit_SetSpeakerPreferred([MarshalAs(UnmanagedType.I1)] bool preferred); + + [DllImport("__Internal")] + private static extern IntPtr LiveKit_GetCurrentOutputRoutes(); + + [DllImport("__Internal")] + private static extern void LiveKit_FreeRouteString(IntPtr routes); + + // The native callback slot is registered once for the app lifetime (matching + // the plugin's app-lifetime notification observers) and fans out to the live + // controllers; keeping the delegate in a static field pins it for the native + // side. Instances add and remove themselves under StaticGate. + private static readonly object StaticGate = new object(); + private static readonly List LiveControllers = new List(); + private static readonly RouteChangeDelegate NativeRouteChanged = OnNativeRouteChanged; + private static bool _callbackRegistered; + + private readonly object _gate = new object(); + // The FFI recording list (a single placeholder for the OS default input), + // captured once: route changes never affect it and re-querying the FFI from + // the route callback would be wasted work. + private readonly List _recordingSnapshot; + private string _lastSignature; + private bool _disposed; + + public event Action, IReadOnlyList> DevicesChanged; + + internal IosRouteController(PlatformAudio owner, IReadOnlyList initialPreference) + { + _recordingSnapshot = owner.GetDevicesViaFfi().Recording; + + ApplyOutputPreference(initialPreference); + _lastSignature = Signature(QueryCurrentOutputs()); + + lock (StaticGate) + { + LiveControllers.Add(this); + if (!_callbackRegistered) + { + LiveKit_SetRouteChangeCallback(NativeRouteChanged); + _callbackRegistered = true; + } + } + } + + public (List Recording, List Playout) GetDevices() + { + return (new List(_recordingSnapshot), QueryCurrentOutputs()); + } + + public void ApplyOutputPreference(IReadOnlyList ranked) + { + // Reduce the ranked list per the PAR-019 precedence rule: the only part of + // the ranking iOS can express is whether Speaker outranks Earpiece. + var speaker = -1; + var earpiece = -1; + for (var i = 0; i < ranked.Count; i++) + { + if (ranked[i] == AudioOutputKind.Speaker) speaker = i; + else if (ranked[i] == AudioOutputKind.Earpiece) earpiece = i; + } + var speakerPreferred = speaker >= 0 && (earpiece < 0 || speaker < earpiece); + LiveKit_SetSpeakerPreferred(speakerPreferred); + } + + public void SelectOutput(AudioDevice device) + { + throw new NotSupportedException( + "SelectOutput is not supported on iOS: the OS owns output route selection. " + + "Present the system route picker (AVRoutePickerView) instead, or use " + + "OutputPreference / IsSpeakerOutputPreferred for the built-in outputs."); + } + + public void ClearOutputOverride() + { + // No override can exist on iOS: SelectOutput throws. + } + + public void Dispose() + { + lock (StaticGate) + { + LiveControllers.Remove(this); + } + lock (_gate) + { + _disposed = true; + } + } + + /// + /// Native route-change entry point, invoked by the plugin on the iOS main + /// queue (not the Unity main thread; marshals the + /// public event). + /// + [AOT.MonoPInvokeCallback(typeof(RouteChangeDelegate))] + private static void OnNativeRouteChanged() + { + IosRouteController[] controllers; + lock (StaticGate) + { + controllers = LiveControllers.ToArray(); + } + foreach (var controller in controllers) + controller.HandleRouteChanged(); + } + + private void HandleRouteChanged() + { + List playout; + lock (_gate) + { + if (_disposed) return; + + playout = QueryCurrentOutputs(); + var signature = Signature(playout); + if (signature == _lastSignature) return; + _lastSignature = signature; + } + + DevicesChanged?.Invoke(playout, new List(_recordingSnapshot)); + } + + /// + /// The current output route reported by the audio session. On iOS this is the + /// active route (usually one device), not an enumeration of every reachable + /// device — AVAudioSession exposes no such list for outputs. + /// + private static List QueryCurrentOutputs() + { + var devices = new List(); + + var routesPtr = LiveKit_GetCurrentOutputRoutes(); + if (routesPtr == IntPtr.Zero) return devices; + + string routes; + try + { + routes = Marshal.PtrToStringUTF8(routesPtr); + } + finally + { + LiveKit_FreeRouteString(routesPtr); + } + if (string.IsNullOrEmpty(routes)) return devices; + + foreach (var line in routes.Split('\n')) + { + if (line.Length == 0) continue; + var fields = line.Split('\t'); + if (fields.Length != 3) + { + Utils.Warning($"IosRouteController: malformed route entry '{line}'"); + continue; + } + + var kind = int.TryParse(fields[0], out var rawKind) + && Enum.IsDefined(typeof(AudioOutputKind), rawKind) + ? (AudioOutputKind)rawKind + : AudioOutputKind.Unknown; + devices.Add(new AudioDevice + { + Index = (uint)devices.Count, + Name = fields[1], + Guid = fields[2], + Kind = kind, + // Everything in the current route is live output by definition. + IsSelected = true, + }); + } + + return devices; + } + + private static string Signature(List playout) + { + var builder = new StringBuilder(); + foreach (var device in playout) + builder.Append(device.Guid).Append('\u001f').Append((int)device.Kind).Append('\u001e'); + return builder.ToString(); + } + } +} +#endif diff --git a/Runtime/Scripts/Audio/IosRouteController.cs.meta b/Runtime/Scripts/Audio/IosRouteController.cs.meta new file mode 100644 index 00000000..069ce527 --- /dev/null +++ b/Runtime/Scripts/Audio/IosRouteController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b7a83f0ed8eb4ce1bbd824fdf80c424 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index c75bcb90..7a13245e 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -43,6 +43,15 @@ internal static class IOSAudioSessionHelper /// [DllImport("__Internal")] internal static extern void LiveKit_SetAudioEnabled([MarshalAs(UnmanagedType.I1)] bool enabled); + + /// + /// Sets the audio session state (0 idle, 1 playout-only, 2 recording) so the + /// plugin can apply the matching category/mode/options (see the state table in + /// LiveKitAudioSession.mm). Driven by PlatformAudio, which knows whether + /// recording is active and whether call audio is wanted. + /// + [DllImport("__Internal")] + internal static extern void LiveKit_SetSessionState(int state); } #endif @@ -87,14 +96,16 @@ public struct AudioDevice /// public string Guid; /// - /// The kind of output this device represents. - /// where the platform does not report a type — currently all devices: no routing - /// backend classifies devices yet. + /// The kind of output this device represents. Reported on iOS for playout devices + /// (classified from the audio session's current route); where the platform does not report a type — + /// recording devices, desktop, and Android, which has no routing backend yet. /// public AudioOutputKind Kind; /// - /// Whether this device is the active output route. Only meaningful once a platform - /// routing backend reports selection state — currently always false. + /// Whether this device is the active output route. Reported on iOS for playout + /// devices; false where no routing backend reports selection state — recording + /// devices, desktop, and Android, which has no routing backend yet. /// public bool IsSelected; } @@ -129,6 +140,24 @@ public sealed class PlatformAudio : IDisposable // Tracks live PlatformAudio instances so the iOS audio session is restored // only when the last one is disposed (aligned with the native ADM ref-count). private static int _instanceCount; + + // Inputs of the iOS session-state machine (see the state table in + // LiveKitAudioSession.mm). PlatformAudio is the driver because it is the one + // that knows both: whether recording is active (its own StartRecording/ + // StopRecording calls) and whether call audio is wanted (SetSessionAudioEnabled). + private const int IosSessionStateIdle = 0; + private const int IosSessionStatePlayoutOnly = 1; + private const int IosSessionStateRecording = 2; + private bool _iosRecordingActive; + private bool _iosSessionAudioEnabled = true; + + private void UpdateIosSessionState() + { + var state = !_iosSessionAudioEnabled ? IosSessionStateIdle + : _iosRecordingActive ? IosSessionStateRecording + : IosSessionStatePlayoutOnly; + IOSAudioSessionHelper.LiveKit_SetSessionState(state); + } #endif private static readonly AudioOutputKind[] DefaultOutputPreference = @@ -155,9 +184,12 @@ public sealed class PlatformAudio : IDisposable /// This must be called before creating any PlatformAudioSource or connecting /// to a room if you want automatic speaker playout for remote audio. /// - /// On iOS, this automatically configures the audio session for VoIP mode - /// (PlayAndRecord category with VideoChat mode) to enable hardware echo - /// cancellation and microphone input. + /// On iOS, this automatically configures the audio session for VoIP use and + /// takes app ownership of it. The session's mode follows the call state: a + /// voice/video-chat mode (enabling hardware echo cancellation) while recording + /// is active, and a music-friendly default mode otherwise (see + /// / / + /// ). /// /// /// Thrown if the platform ADM could not be initialized (e.g., no audio devices, @@ -186,6 +218,13 @@ public PlatformAudio() _routeController = CreateRouteController(); _routeController.DevicesChanged += OnRouteControllerDevicesChanged; +#if UNITY_IOS && !UNITY_EDITOR + // A fresh instance starts in the playout-only state (recording has not + // been started); this matches the plugin's post-configure default, so the + // call is a no-op unless an earlier instance left another state behind. + UpdateIosSessionState(); +#endif + Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices"); #if UNITY_IOS && !UNITY_EDITOR @@ -200,7 +239,7 @@ private IRouteController CreateRouteController() #if UNITY_ANDROID && !UNITY_EDITOR return new UnsupportedRouteController(this, "Android"); #elif UNITY_IOS && !UNITY_EDITOR - return new UnsupportedRouteController(this, "iOS"); + return new IosRouteController(this, _outputPreference); #else return new DesktopRouteController(this); #endif @@ -213,19 +252,23 @@ private IRouteController CreateRouteController() /// - Desktop (Windows/macOS/Linux): returns the full list of microphones and /// speakers reported by the OS. Devices can be selected with /// / . - /// - iOS and Android: returns a single placeholder entry at index 0 for each - /// list, representing the system's currently selected default input/output. - /// The OS owns audio routing on these platforms (AVAudioSession on iOS, - /// AudioManager on Android), so individual devices are not enumerated and - /// selecting one is a no-op (see / + /// - iOS: the playout list is the audio session's current output route (usually + /// one device, with and + /// set) — iOS does not enumerate every + /// reachable output device. The recording list is a single placeholder entry + /// for the OS default input. + /// - Android: returns a single placeholder entry at index 0 for each list, + /// representing the system's currently selected default input/output. The OS + /// owns audio routing (AudioManager), so individual devices are not enumerated + /// and selecting one is a no-op (see / /// ). /// /// /// A tuple containing: /// - Recording: List of available microphones (on iOS/Android, a single /// placeholder for the OS default input) - /// - Playout: List of available speakers/headphones (on iOS/Android, a single - /// placeholder for the OS default output) + /// - Playout: List of available speakers/headphones (on iOS, the current output + /// route; on Android, a single placeholder for the OS default output) /// /// /// Thrown if device enumeration failed. @@ -290,11 +333,12 @@ private IRouteController CreateRouteController() /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority /// over the built-in outputs, so the Speaker/Earpiece relative order — i.e. /// — is the only part of the ranking with an - /// effect. On Android the full ranking applies. On desktop, output is selected - /// per device ( / ) - /// and the ranking has no routing effect. The mobile routing backends are not - /// implemented yet in this version: on Android and iOS the value is currently - /// stored and round-trips, but has no routing effect either. + /// effect; it is applied through the audio session mode and takes effect + /// immediately, including mid-call. On desktop, output is selected per device + /// ( / ) and the + /// ranking has no routing effect. The Android routing backend is not implemented + /// yet in this version: the value is stored and round-trips, but has no routing + /// effect there. /// /// Thrown if set to null. /// @@ -340,11 +384,13 @@ public IReadOnlyList OutputPreference /// /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority /// over the built-in outputs, so this bool is the only part of the ranking with an - /// effect. On Android the full ranking applies. On desktop, output is selected - /// per device ( / ) - /// and the ranking has no routing effect. The mobile routing backends are not - /// implemented yet in this version: on Android and iOS the value is currently - /// stored and round-trips, but has no routing effect either. + /// 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. On desktop, output is + /// selected per device ( / + /// ) and the ranking has no routing effect. + /// The Android routing backend is not implemented yet in this version: the value + /// is stored and round-trips, but has no routing effect there. /// public bool IsSpeakerOutputPreferred { @@ -395,16 +441,20 @@ public bool IsSpeakerOutputPreferred /// when set, otherwise by index and name. /// /// Platform notes: on desktop this selects the device like - /// . On Android and iOS the routing backends - /// are not implemented yet in this version and this method throws - /// . + /// . On iOS the OS owns output route + /// selection and this method throws — present + /// the system route picker (AVRoutePickerView) instead, or use + /// / for the + /// built-in outputs. On Android the routing backend is not implemented yet in + /// this version and this method also throws. /// /// A playout device from . /// /// Thrown if the device does not match any current playout device. /// /// - /// Thrown on Android and iOS, where no routing backend exists yet. + /// Thrown on iOS (the OS owns route selection) and on Android (no routing + /// backend exists yet). /// public void SelectOutput(AudioDevice device) { @@ -430,8 +480,8 @@ public void SelectOutput(AudioDevice device) /// policy applies again. /// /// Platform notes: on desktop there is no automatic policy to fall back to yet, so - /// clearing keeps the currently selected device (no-op). On Android and iOS no - /// override can exist yet ( throws), so this is a no-op + /// clearing keeps the currently selected device (no-op). On iOS and Android no + /// override can exist ( throws), so this is a no-op /// there as well. /// public void ClearOutputOverride() @@ -443,8 +493,10 @@ public void ClearOutputOverride() /// Raised when the set of available audio devices changes, with the current playout /// and recording device lists. Raised on the Unity main thread. /// - /// No implementation raises this event yet in this version: desktop hot-plug events - /// and the mobile routing backends that produce it are not implemented. Subscribing + /// On iOS this fires when the audio session's output route changes (headset + /// plugged/unplugged, Bluetooth connected, speaker/earpiece switches); the playout + /// list is the new route. Desktop hot-plug events and the Android backend are not + /// implemented yet in this version, so the event is never raised there. Subscribing /// and unsubscribing is safe at any time, including after . /// public event Action, IReadOnlyList> DevicesChanged; @@ -571,6 +623,9 @@ public void SetPlayoutDevice(string deviceId) /// Recording is started automatically when PlatformAudio is created. /// Use this to resume recording after calling StopRecording. /// This turns on the system's recording privacy indicator (e.g., on macOS/iOS). + /// On iOS this also switches the audio session to its recording state + /// (voice/video-chat mode per , enabling + /// hardware echo cancellation). /// /// /// Thrown if the operation failed. @@ -610,6 +665,11 @@ public IEnumerator StartRecording() if (res.StartRecording.HasError && !string.IsNullOrEmpty(res.StartRecording.Error)) throw new InvalidOperationException($"Failed to start recording: {res.StartRecording.Error}"); +#if UNITY_IOS && !UNITY_EDITOR + _iosRecordingActive = true; + UpdateIosSessionState(); +#endif + Utils.Debug("PlatformAudio: started recording"); // Ensures this method is always a valid iterator even when the PLATFORM_ANDROID @@ -624,6 +684,8 @@ public IEnumerator StartRecording() /// Use this to temporarily stop recording without disposing PlatformAudio. /// This turns off the system's recording privacy indicator (e.g., on macOS/iOS). /// Call StartRecording to resume recording. + /// On iOS this also switches the audio session back to its playout-only state + /// (music-friendly default mode). /// /// /// Thrown if the operation failed. @@ -639,6 +701,11 @@ public void StopRecording() if (res.StopRecording.HasError && !string.IsNullOrEmpty(res.StopRecording.Error)) throw new InvalidOperationException($"Failed to stop recording: {res.StopRecording.Error}"); +#if UNITY_IOS && !UNITY_EDITOR + _iosRecordingActive = false; + UpdateIosSessionState(); +#endif + Utils.Debug("PlatformAudio: stopped recording"); } @@ -649,7 +716,8 @@ public void StopRecording() /// of the shared AVAudioSession. It is enabled by default when PlatformAudio is /// created, so this only needs to be called to false when leaving a room /// (and back to true when rejoining). Disabling stops the microphone/ - /// remote audio path and the hardware voice processing, but keeps the audio + /// remote audio path and the hardware voice processing, and drops the session + /// to its idle state (music-friendly default mode), but keeps the audio /// session active so other Unity audio (e.g. background music) is not /// interrupted — which is why Unity audio survives a hang-up. /// @@ -660,6 +728,8 @@ public void SetSessionAudioEnabled(bool enabled) { #if UNITY_IOS && !UNITY_EDITOR IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled); + _iosSessionAudioEnabled = enabled; + UpdateIosSessionState(); #endif Utils.Debug($"PlatformAudio: session audio enabled={enabled}"); } diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs index 31d7861b..edb8dc2a 100644 --- a/Runtime/Scripts/Audio/RouteController.cs +++ b/Runtime/Scripts/Audio/RouteController.cs @@ -88,7 +88,7 @@ public void Dispose() /// /// Placeholder backend for platforms whose routing implementation has not landed yet - /// (Android, iOS). Device snapshots still work through the FFI (a single placeholder + /// (Android). Device snapshots still work through the FFI (a single placeholder /// entry for the OS default input/output); the routing verbs throw or no-op as /// documented on the public API. /// From 4c6f7748ad6f2b88594139af57872487d14c4130 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:41:53 +0200 Subject: [PATCH 15/35] Rebuild the audio unit after mid-call session mode changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADM skips the VPIO rebuild on route changes that keep the hardware sample rate (HandleValidRouteChange -> HandleSampleRateChange no-ops), so a live VoiceChat -> VideoChat switch left the unit calibrated for the receiver — device-observed as an attenuated loudspeaker after an earpiece -> speaker toggle. Cycle isAudioEnabled after a mode change (when call audio is wanted) to force a clean rebuild against the new route, the same mechanism the foreground recovery already uses. The recovery and configure paths pass NO: the former cycles itself, the latter runs before the unit exists. Co-Authored-By: Claude Fable 5 --- Runtime/Plugins/iOS/LiveKitAudioSession.mm | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index c2e1e766..958fb3a7 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -218,9 +218,20 @@ static void LiveKit_MirrorWebRTCConfiguration(NSString* mode, /// Applies the config derived from (s_sessionState, s_speakerPreferred) to the /// session and mirrors it into the WebRTC snapshot. Logs expected vs. actual so /// device tests can see who won when something else reconfigures the session. -static void LiveKit_ApplySessionConfig(NSString* reason) { +/// +/// rebuildAudioUnitOnModeChange: the ADM does not rebuild its VPIO unit on a +/// route change that keeps the hardware sample rate (HandleValidRouteChange -> +/// HandleSampleRateChange no-ops when the audio parameters are intact), so a +/// live mode switch leaves the unit calibrated for the previous route -- +/// device-observed as an attenuated loudspeaker after an earpiece -> speaker +/// toggle. Passing YES cycles isAudioEnabled after a mode change to force a +/// clean rebuild against the new route, at the cost of a brief audio gap. +/// Callers that handle the rebuild themselves (foreground recovery) or run +/// before the unit exists (configure) pass NO. +static void LiveKit_ApplySessionConfig(NSString* reason, BOOL rebuildAudioUnitOnModeChange) { NSString* mode = LiveKit_DesiredMode(); AVAudioSessionCategoryOptions options = LiveKit_DesiredOptions(); + BOOL modeChanged = ![[AVAudioSession sharedInstance].mode isEqualToString:mode]; id rtc = LiveKit_RTCSession(); NSError* error = nil; @@ -252,6 +263,13 @@ static void LiveKit_ApplySessionConfig(NSString* reason) { " -> actual category=%@ mode=%@ options=%lu", reason, s_sessionState, s_speakerPreferred, mode, (unsigned long)options, current.category, current.mode, (unsigned long)current.categoryOptions); + + if (rebuildAudioUnitOnModeChange && modeChanged && s_audioDesired && rtc != nil) { + rtc.isAudioEnabled = NO; + rtc.isAudioEnabled = YES; + NSLog(@"LiveKit: cycled isAudioEnabled to rebuild the audio unit after mode change (%@)", + reason); + } } /// Re-applies the current state's config and reactivates the session, then @@ -276,7 +294,7 @@ static void LiveKit_ScheduleSessionRecovery() { NSLog(@"LiveKit: foreground recovery; session before re-assert: category=%@ mode=%@ options=%lu", session.category, session.mode, (unsigned long)session.categoryOptions); - LiveKit_ApplySessionConfig(@"foreground recovery"); + LiveKit_ApplySessionConfig(@"foreground recovery", NO); // Reactivate directly on AVAudioSession: the OS deactivated the hardware // session during the interruption, but RTCAudioSession's activation @@ -415,7 +433,7 @@ void LiveKit_ConfigureAudioSessionForVoIP() { rtc.useManualAudio = YES; } - LiveKit_ApplySessionConfig(@"configure"); + LiveKit_ApplySessionConfig(@"configure", NO); if (rtc == nil) { // RTCAudioSession unavailable: activate AVAudioSession directly (legacy). @@ -482,7 +500,7 @@ void LiveKit_SetSpeakerPreferred(bool preferred) { } s_speakerPreferred = value; if (s_liveKitConfigured) { - LiveKit_ApplySessionConfig(@"speaker preference"); + LiveKit_ApplySessionConfig(@"speaker preference", YES); } } @@ -499,7 +517,7 @@ void LiveKit_SetSessionState(int state) { } s_sessionState = state; if (s_liveKitConfigured) { - LiveKit_ApplySessionConfig(@"session state"); + LiveKit_ApplySessionConfig(@"session state", YES); } } From 4e885d428a85601fea07ae344b660f9e85859f13 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:05:41 +0200 Subject: [PATCH 16/35] Migrate the samples off C#-JNI routing onto the SDK routing API (PAR-011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With PAR-019/020/021 the SDK owns output routing (OutputPreference policy, sticky SelectOutput, DevicesChanged, communication-mode ownership on Android), so the sample-level reimplementation is deleted: RouteRank, the AudioManager JNI plumbing (setMode/setCommunicationDevice, the communication-device change listener) and the route watchdog poll, plus their call sites in MeetManager and LiveKitAgentSession. The controller now only demonstrates the API: it relies on the default OutputPreference ranking (customizing it is shown as a one-liner) and logs DevicesChanged with device kind and selection state. The Android capture lifecycle policy stays: capture starts at call begin and keeps running while muted, since Android 13 only honors the communication-mode request — and with it the SDK's route pin — while the app has active voice-communication capture. Both PlatformAudioController copies (Meet + Agents) stay byte-identical. Co-Authored-By: Claude Fable 5 --- .../Runtime/Agent/LiveKitAgentSession.cs | 7 - .../Runtime/Agent/PlatformAudioController.cs | 418 +++--------------- Samples~/Meet/Assets/Runtime/MeetManager.cs | 9 +- .../Assets/Runtime/PlatformAudioController.cs | 418 +++--------------- 4 files changed, 103 insertions(+), 749 deletions(-) diff --git a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs index 0fc59620..a2337bc1 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs @@ -91,13 +91,6 @@ IEnumerator Connect() yield break; } -#if UNITY_ANDROID && !UNITY_EDITOR - // Poll fallback for routing changes that fire no communication-device event - // (e.g. a Bluetooth headset leaving the device list after its SCO link already - // dropped) — see PlatformAudioController.AndroidRouteWatchdog. - StartCoroutine(_audio.AndroidRouteWatchdog()); -#endif - _room = new Room(); Debug.Log($"[LiveKitAgentSession] Connecting to '{details.ServerUrl}'..."); diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 93f01920..2daf215e 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -1,5 +1,7 @@ using System; using System.Collections; +using System.Collections.Generic; +using System.Text; using LiveKit; using LiveKit.Proto; using UnityEngine; @@ -8,12 +10,17 @@ // the configured audio processing (AEC/NS/AGC) and publishes it as a LiveKit track, and // selects the default playout device through which remote tracks are played back // automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays -// alive; Dispose tears everything down in dependency order. On Android the controller -// also owns the voice-communication audio session (mode + output route, loudspeaker -// over earpiece) for its whole lifetime; an active mic capture is what makes that -// session authoritative, so start it with StartCapture when the call begins (even -// when joining muted) — it then stays open across mute cycles until StopCapture when -// the call ends. See StartCapture, SetupAndroidCommunicationAudio and Unpublish. +// alive; Dispose tears everything down in dependency order. +// +// Output routing is owned by the SDK: PlatformAudio routes to the best available output +// per its ranked OutputPreference (default: Bluetooth > wired headset > speaker > +// earpiece) and keeps the route pinned across device changes for its whole lifetime. +// This controller only demonstrates the observability side by logging DevicesChanged. +// On Android an active mic capture is what keeps the SDK's route authoritative (since +// Android 13 the OS only honors an app's communication-mode request while it has active +// voice-communication capture), so start the capture with StartCapture when the call +// begins (even when joining muted) — it then stays open across mute cycles until +// StopCapture when the call ends. See StartCapture and Unpublish. public sealed class PlatformAudioController : IDisposable { readonly string _trackName; @@ -25,11 +32,6 @@ public sealed class PlatformAudioController : IDisposable Room _room; bool _isRecording; -#if UNITY_ANDROID && !UNITY_EDITOR - CommunicationDeviceListener _routeListener; - int _savedAudioMode; // MODE_NORMAL unless something else was active at Initialize -#endif - public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -47,12 +49,11 @@ public bool Initialize() if (!InitializePlatformAudio()) return false; -#if UNITY_ANDROID && !UNITY_EDITOR - // Remote playout through the ADM starts at room connect regardless of whether - // the mic is ever published, so the audio session must be set up for the whole - // controller lifetime. - SetupAndroidCommunicationAudio(); -#endif + // The SDK routes output automatically from here on; the default + // PlatformAudio.OutputPreference ranking is already what a call app wants. + // A custom ranking would be a one-liner: + // _platformAudio.OutputPreference = new[] { AudioOutputKind.WiredHeadset, AudioOutputKind.Speaker }; + _platformAudio.DevicesChanged += OnDevicesChanged; return true; } @@ -77,13 +78,6 @@ public IEnumerator Publish(Room room) // Unpublish). yield return StartCapture(); -#if UNITY_ANDROID && !UNITY_EDITOR - // Re-assert the preferred route immediately: the route watchdog would pick up - // any missed device change within its poll interval, but unmuting is a natural - // point to remove that latency. - ApplyAndroidCommunicationRoute(); -#endif - _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -110,13 +104,11 @@ public IEnumerator Publish(Room room) // running capture. On macOS/iOS this turns on the recording privacy indicator and // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime // permission dialog. On Android call this as soon as the call starts, even when - // joining muted: since Android 13 the app's MODE_IN_COMMUNICATION request — and - // with it the communication-device pin — is only honored while the app has ACTIVE + // joining muted: since Android 13 the app's communication-mode request — and with + // it the SDK's output route pin — is only honored while the app has ACTIVE // voice-communication capture or playback, and the ADM's playout stream does not - // register as active, only the recorder does. Without a running capture the pin is - // un-owned: it happens to hold in the simple fresh-session case, but after a - // Bluetooth connect/disconnect episode the platform reasserts the earpiece and - // wins against the change listener's re-pin. + // register as active, only the recorder does. The SDK re-asserts its routing policy + // whenever the capture (re)starts. public IEnumerator StartCapture() { if (_platformAudio == null) @@ -130,12 +122,6 @@ public IEnumerator StartCapture() Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); _isRecording = true; - -#if UNITY_ANDROID && !UNITY_EDITOR - // The pin only became authoritative once the capture went active — re-assert - // the preferred route in case the platform moved it while the mode was un-owned. - ApplyAndroidCommunicationRoute(); -#endif } // Tears down the mic capture and track but keeps the ADM alive: remote playout @@ -153,11 +139,10 @@ public void Unpublish() #if UNITY_ANDROID && !UNITY_EDITOR // Keep the capture stream open while muted. Since Android 13, AudioService only - // honors this app's MODE_IN_COMMUNICATION request — and with it the - // communication-device pin — while the app has ACTIVE voice-communication - // capture or playback: with the recorder stopped, the mode-owner stack reports - // "Active: false", the mode drops back to MODE_NORMAL, and Telecom re-asserts - // the earpiece route every ~6 s. The track is unpublished and its source + // honors this app's communication-mode request — and with it the SDK's output + // route pin — while the app has ACTIVE voice-communication capture or playback: + // with the recorder stopped, the mode drops back to MODE_NORMAL and the platform + // re-asserts the earpiece route. The track is unpublished and its source // disposed below, so no audio reaches the room, but the OS mic-in-use indicator // stays on while muted — same as other conferencing apps. Recording stops in // StopCapture (call end) or Dispose. @@ -167,11 +152,6 @@ public void Unpublish() _source?.Dispose(); _source = null; - - // The Android route override is likewise deliberately kept: the ADM continues - // playing remote audio while the mic is unpublished (listen-only / muted), and - // clearing the route here would drop that playout back onto the earpiece. - // Teardown happens in Dispose. } // Gates call audio on the ADM while the app keeps ownership of the audio session. @@ -213,13 +193,7 @@ bool InitializePlatformAudio() $"({_platformAudio.RecordingDeviceCount} mic(s), {_platformAudio.PlayoutDeviceCount} speaker(s))."); var (recording, playout) = _platformAudio.GetDevices(); - Debug.Log("[PlatformAudioController] Recording devices:"); - foreach (var device in recording) - Debug.Log($" [{device.Index}] {device.Name}"); - - Debug.Log("[PlatformAudioController] Playout devices:"); - foreach (var device in playout) - Debug.Log($" [{device.Index}] {device.Name}"); + Debug.Log(FormatDeviceLists(playout, recording)); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -237,333 +211,43 @@ bool InitializePlatformAudio() } } -#if UNITY_ANDROID && !UNITY_EDITOR - // Preference order for the voice-communication output route: - // Bluetooth > wired headset > built-in loudspeaker. The earpiece (and anything - // unrecognized) is never picked explicitly — when nothing ranked is available we - // leave the OS default in place, which on a phone IS the earpiece, so it naturally - // comes last. Note: Bluetooth devices only show up as communication devices if they - // support a voice profile (HFP/LE Audio); A2DP-only speakers can't carry call audio - // on Android and fall through to the loudspeaker. - static int RouteRank(int deviceType) - { - switch (deviceType) - { - case 26: // AudioDeviceInfo.TYPE_BLE_HEADSET - case 27: // AudioDeviceInfo.TYPE_BLE_SPEAKER - case 7: // AudioDeviceInfo.TYPE_BLUETOOTH_SCO - case 23: // AudioDeviceInfo.TYPE_HEARING_AID - return 0; - case 3: // AudioDeviceInfo.TYPE_WIRED_HEADSET - case 4: // AudioDeviceInfo.TYPE_WIRED_HEADPHONES - case 22: // AudioDeviceInfo.TYPE_USB_HEADSET - return 1; - case 2: // AudioDeviceInfo.TYPE_BUILTIN_SPEAKER - return 2; - default: - return int.MaxValue; - } - } - - static int AndroidSdkInt() - { - using var version = new AndroidJavaClass("android.os.Build$VERSION"); - return version.GetStatic("SDK_INT"); - } - - // Caller owns the returned object (wrap it in `using var`). - static AndroidJavaObject GetAudioManager() + // Demonstrates the SDK's routing observability: the routing backend raises + // DevicesChanged (on the Unity main thread) whenever the available devices or the + // active route change — headset plugged/unplugged, Bluetooth connected, the route + // re-pinned after a device disappeared. An app would refresh its device picker here. + static void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - return activity.Call("getSystemService", "audio"); + Debug.Log("[PlatformAudioController] Audio devices changed.\n" + + FormatDeviceLists(playout, recording)); } - // C#-side implementation of the Java callback interface. AndroidJavaProxy can only - // implement interfaces, which is why this listens for communication-device changes - // rather than subclassing android.media.AudioDeviceCallback (an abstract class). - sealed class CommunicationDeviceListener : AndroidJavaProxy + static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) { - public CommunicationDeviceListener() - : base("android.media.AudioManager$OnCommunicationDeviceChangedListener") { } - - // Invoked by Android on the activity's main executor — a JVM-attached thread, - // but NOT the Unity main thread: keep the body restricted to JNI and Debug.Log. - public void onCommunicationDeviceChanged(AndroidJavaObject device) + var sb = new StringBuilder("Playout devices:"); + foreach (var device in playout) { - int type = device != null ? device.Call("getType") : -1; - Debug.Log($"[PlatformAudioController] Communication device changed (type={type}); re-evaluating route."); - device?.Dispose(); - ApplyAndroidCommunicationRoute(); + sb.Append($"\n [{device.Index}] {device.Name} (kind={device.Kind}"); + if (device.IsSelected) + sb.Append(", selected"); + sb.Append(')'); } + sb.Append("\nRecording devices:"); + foreach (var device in recording) + sb.Append($"\n [{device.Index}] {device.Name}"); + return sb.ToString(); } - // Enters voice-communication audio mode, applies the preferred output route, and - // starts watching for route changes — all held until Dispose. Owning - // MODE_IN_COMMUNICATION is what makes the setCommunicationDevice pin authoritative: - // without it the platform periodically reasserts its own default route (observed on - // Pixel 8a: after a Bluetooth session ended, Telecom's CallAudioRouteController - // flipped playout back to the earpiece every ~6 s, endlessly fighting the re-pin). - // Side effects while the mode is held: hardware volume keys control the call stream, - // and Bluetooth audio runs over HFP/SCO (call quality) instead of A2DP — standard - // for call apps. - void SetupAndroidCommunicationAudio() - { - try - { - using var audioManager = GetAudioManager(); - _savedAudioMode = audioManager.Call("getMode"); - audioManager.Call("setMode", 3 /* AudioManager.MODE_IN_COMMUNICATION */); - Debug.Log($"[PlatformAudioController] Audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to enter communication mode: {e.Message}"); - } - - ApplyAndroidCommunicationRoute(); - RegisterAndroidRouteListener(); - } - - void TeardownAndroidCommunicationAudio() - { - // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, - // and a still-registered listener would immediately re-pin the loudspeaker. - UnregisterAndroidRouteListener(); - ClearAndroidCommunicationRoute(); - - try - { - using var audioManager = GetAudioManager(); - audioManager.Call("setMode", _savedAudioMode); - Debug.Log($"[PlatformAudioController] Audio mode restored ({_savedAudioMode})."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to restore audio mode: {e.Message}"); - } - } - - // Re-evaluates the route whenever the OS changes the communication device — most - // importantly when the active device disconnects and playout would otherwise fall - // back to the earpiece. Registered for the whole session (Initialize until Dispose). - void RegisterAndroidRouteListener() - { - try - { - if (AndroidSdkInt() < 31) - return; - - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - using var audioManager = activity.Call("getSystemService", "audio"); - using var executor = activity.Call("getMainExecutor"); - - _routeListener = new CommunicationDeviceListener(); - audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _routeListener); - Debug.Log("[PlatformAudioController] Registered communication device listener."); - } - catch (Exception e) - { - _routeListener = null; - Debug.LogWarning($"[PlatformAudioController] Failed to register device listener: {e.Message}"); - } - } - - void UnregisterAndroidRouteListener() - { - if (_routeListener == null) - return; - try - { - using var audioManager = GetAudioManager(); - audioManager.Call("removeOnCommunicationDeviceChangedListener", _routeListener); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to unregister device listener: {e.Message}"); - } - _routeListener = null; - } - - // Poll fallback for route changes that fire no communication-device event, run via - // StartCoroutine for the controller's whole lifetime. Device-verified gap on - // Pixel 8a (Android 16): when the Bluetooth headset powers off mid-call, SCO drops - // first and the communication device falls back to the earpiece while the headset - // is still in getAvailableCommunicationDevices — the listener's re-evaluation at - // that point still ranks the (dying) headset best. The headset leaves the device - // list up to ~10 s later WITHOUT another communication-device change (the device - // stays "earpiece"), so the listener never fires again and playout is stuck on the - // earpiece. Only a device-list diff catches that transition, and - // AudioDeviceCallback is an abstract class that AndroidJavaProxy cannot implement, - // hence polling. Also covers devices ADDED while a pin is active, which equally - // fires no event. - public IEnumerator AndroidRouteWatchdog() - { - var interval = new WaitForSeconds(1.5f); - while (IsInitialized) - { - if (AndroidRouteNeedsReapply()) - { - Debug.Log("[PlatformAudioController] Route watchdog detected divergence; re-evaluating."); - ApplyAndroidCommunicationRoute(); - } - yield return interval; - } - } - - // True when a strictly better-ranked communication device is available than the - // one currently active — the pinned device vanished and the OS fell back to the - // earpiece, or a better device appeared without an event. Rank (not id) comparison - // on purpose: a headset can expose several same-rank entries (BLE + SCO) and which - // of those the OS activates is its call, not a divergence to correct. Kept - // separate from ApplyAndroidCommunicationRoute so the quiescent poll stays two - // JNI queries with no logging. - static bool AndroidRouteNeedsReapply() - { - try - { - if (AndroidSdkInt() < 31) - return false; - - using var audioManager = GetAudioManager(); - using var current = audioManager.Call("getCommunicationDevice"); - int currentRank = current != null ? RouteRank(current.Call("getType")) : int.MaxValue; - - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) - { - using var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - bestRank = rank; - } - return bestRank < currentRank; - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Route watchdog check failed: {e.Message}"); - return false; - } - } - - // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a - // documented no-op there): remote tracks play through a voice-communication stream, - // whose default route is the earpiece. Pick the best route per RouteRank via - // AudioManager — setCommunicationDevice on Android 12+ (API 31), where - // setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. - // The route is session-scoped: applied in Initialize, re-evaluated on each Publish - // and on every OS communication-device change (see RegisterAndroidRouteListener), - // and cleared in Dispose. Re-pinning is skipped when the best-ranked device is - // already active — our own setCommunicationDevice fires the change listener, and - // the no-op check is what stops that feedback loop. The pin only holds while the - // app owns MODE_IN_COMMUNICATION — see SetupAndroidCommunicationAudio. - static void ApplyAndroidCommunicationRoute() - { - try - { - using var audioManager = GetAudioManager(); - - if (AndroidSdkInt() >= 31) - { - using var current = audioManager.Call("getCommunicationDevice"); - int currentId = current != null ? current.Call("getId") : -1; - - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - AndroidJavaObject best = null; - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) - { - var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - { - best?.Dispose(); - best = device; - bestRank = rank; - } - else - { - device.Dispose(); - } - } - - if (best != null) - { - if (best.Call("getId") == currentId) - { - Debug.Log($"[PlatformAudioController] Best route (type={best.Call("getType")}) already active; skipping re-pin."); - } - else - { - bool ok = audioManager.Call("setCommunicationDevice", best); - Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); - } - best.Dispose(); - } - else - { - Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); - } - } - else - { - // Legacy path (pre-API-31). If a Bluetooth or wired output is attached, - // leave routing to the OS instead of hijacking it with the loudspeaker; - // proper legacy Bluetooth SCO management (startBluetoothSco) is out of - // scope for this demo. The AudioManager queries are deprecated but this - // branch only ever runs on old devices. - if (audioManager.Call("isBluetoothA2dpOn") - || audioManager.Call("isBluetoothScoOn") - || audioManager.Call("isWiredHeadsetOn")) - { - Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); - return; - } - audioManager.Call("setSpeakerphoneOn", true); - Debug.Log("[PlatformAudioController] setSpeakerphoneOn(true)"); - } - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); - } - } - - // Hands output routing back to the OS default. Only called from Dispose: the route - // is session-scoped on purpose (see Unpublish). - static void ClearAndroidCommunicationRoute() - { - try - { - using var audioManager = GetAudioManager(); - if (AndroidSdkInt() >= 31) - audioManager.Call("clearCommunicationDevice"); - else - audioManager.Call("setSpeakerphoneOn", false); - Debug.Log("[PlatformAudioController] Restored default audio route."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to clear communication route: {e.Message}"); - } - } -#endif - public void Dispose() { Unpublish(); StopCapture(); - _platformAudio?.Dispose(); - _platformAudio = null; - -#if UNITY_ANDROID && !UNITY_EDITOR - TeardownAndroidCommunicationAudio(); -#endif + if (_platformAudio != null) + { + _platformAudio.DevicesChanged -= OnDevicesChanged; + _platformAudio.Dispose(); + _platformAudio = null; + } _room = null; } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index a35a4cd8..e3fd7b23 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -111,13 +111,6 @@ private void InitializePlatformAudio() } Debug.Log($"PlatformAudio ready. AEC={echoCancellation}, NS={noiseSuppression}, AGC={autoGainControl}, HW={preferHardwareProcessing}"); - -#if UNITY_ANDROID && !UNITY_EDITOR - // Poll fallback for routing changes that fire no communication-device event - // (e.g. a Bluetooth headset leaving the device list after its SCO link already - // dropped) — see PlatformAudioController.AndroidRouteWatchdog. - StartCoroutine(_platformAudioController.AndroidRouteWatchdog()); -#endif } private void OnApplicationPause(bool pause) @@ -259,7 +252,7 @@ private IEnumerator ConnectToRoom() #if UNITY_ANDROID && !UNITY_EDITOR // Keep the mic capture running for the whole call, even while muted: without // an active capture Android treats the communication-mode request as inactive - // and the speaker route pin is not honored after a Bluetooth episode — see + // and the SDK's output route pin is not honored after a Bluetooth episode — see // PlatformAudioController.StartCapture. Publishing (unmuting) reuses the capture. if (usePlatformAudio && _platformAudioController != null) StartCoroutine(_platformAudioController.StartCapture()); diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 93f01920..2daf215e 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -1,5 +1,7 @@ using System; using System.Collections; +using System.Collections.Generic; +using System.Text; using LiveKit; using LiveKit.Proto; using UnityEngine; @@ -8,12 +10,17 @@ // the configured audio processing (AEC/NS/AGC) and publishes it as a LiveKit track, and // selects the default playout device through which remote tracks are played back // automatically. Publish/Unpublish can be cycled (e.g. a mute toggle) while the ADM stays -// alive; Dispose tears everything down in dependency order. On Android the controller -// also owns the voice-communication audio session (mode + output route, loudspeaker -// over earpiece) for its whole lifetime; an active mic capture is what makes that -// session authoritative, so start it with StartCapture when the call begins (even -// when joining muted) — it then stays open across mute cycles until StopCapture when -// the call ends. See StartCapture, SetupAndroidCommunicationAudio and Unpublish. +// alive; Dispose tears everything down in dependency order. +// +// Output routing is owned by the SDK: PlatformAudio routes to the best available output +// per its ranked OutputPreference (default: Bluetooth > wired headset > speaker > +// earpiece) and keeps the route pinned across device changes for its whole lifetime. +// This controller only demonstrates the observability side by logging DevicesChanged. +// On Android an active mic capture is what keeps the SDK's route authoritative (since +// Android 13 the OS only honors an app's communication-mode request while it has active +// voice-communication capture), so start the capture with StartCapture when the call +// begins (even when joining muted) — it then stays open across mute cycles until +// StopCapture when the call ends. See StartCapture and Unpublish. public sealed class PlatformAudioController : IDisposable { readonly string _trackName; @@ -25,11 +32,6 @@ public sealed class PlatformAudioController : IDisposable Room _room; bool _isRecording; -#if UNITY_ANDROID && !UNITY_EDITOR - CommunicationDeviceListener _routeListener; - int _savedAudioMode; // MODE_NORMAL unless something else was active at Initialize -#endif - public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -47,12 +49,11 @@ public bool Initialize() if (!InitializePlatformAudio()) return false; -#if UNITY_ANDROID && !UNITY_EDITOR - // Remote playout through the ADM starts at room connect regardless of whether - // the mic is ever published, so the audio session must be set up for the whole - // controller lifetime. - SetupAndroidCommunicationAudio(); -#endif + // The SDK routes output automatically from here on; the default + // PlatformAudio.OutputPreference ranking is already what a call app wants. + // A custom ranking would be a one-liner: + // _platformAudio.OutputPreference = new[] { AudioOutputKind.WiredHeadset, AudioOutputKind.Speaker }; + _platformAudio.DevicesChanged += OnDevicesChanged; return true; } @@ -77,13 +78,6 @@ public IEnumerator Publish(Room room) // Unpublish). yield return StartCapture(); -#if UNITY_ANDROID && !UNITY_EDITOR - // Re-assert the preferred route immediately: the route watchdog would pick up - // any missed device change within its poll interval, but unmuting is a natural - // point to remove that latency. - ApplyAndroidCommunicationRoute(); -#endif - _source = new PlatformAudioSource(_platformAudio, _audioOptions); _track = LocalAudioTrack.CreateAudioTrack(_trackName, _source, _room); @@ -110,13 +104,11 @@ public IEnumerator Publish(Room room) // running capture. On macOS/iOS this turns on the recording privacy indicator and // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime // permission dialog. On Android call this as soon as the call starts, even when - // joining muted: since Android 13 the app's MODE_IN_COMMUNICATION request — and - // with it the communication-device pin — is only honored while the app has ACTIVE + // joining muted: since Android 13 the app's communication-mode request — and with + // it the SDK's output route pin — is only honored while the app has ACTIVE // voice-communication capture or playback, and the ADM's playout stream does not - // register as active, only the recorder does. Without a running capture the pin is - // un-owned: it happens to hold in the simple fresh-session case, but after a - // Bluetooth connect/disconnect episode the platform reasserts the earpiece and - // wins against the change listener's re-pin. + // register as active, only the recorder does. The SDK re-asserts its routing policy + // whenever the capture (re)starts. public IEnumerator StartCapture() { if (_platformAudio == null) @@ -130,12 +122,6 @@ public IEnumerator StartCapture() Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); _isRecording = true; - -#if UNITY_ANDROID && !UNITY_EDITOR - // The pin only became authoritative once the capture went active — re-assert - // the preferred route in case the platform moved it while the mode was un-owned. - ApplyAndroidCommunicationRoute(); -#endif } // Tears down the mic capture and track but keeps the ADM alive: remote playout @@ -153,11 +139,10 @@ public void Unpublish() #if UNITY_ANDROID && !UNITY_EDITOR // Keep the capture stream open while muted. Since Android 13, AudioService only - // honors this app's MODE_IN_COMMUNICATION request — and with it the - // communication-device pin — while the app has ACTIVE voice-communication - // capture or playback: with the recorder stopped, the mode-owner stack reports - // "Active: false", the mode drops back to MODE_NORMAL, and Telecom re-asserts - // the earpiece route every ~6 s. The track is unpublished and its source + // honors this app's communication-mode request — and with it the SDK's output + // route pin — while the app has ACTIVE voice-communication capture or playback: + // with the recorder stopped, the mode drops back to MODE_NORMAL and the platform + // re-asserts the earpiece route. The track is unpublished and its source // disposed below, so no audio reaches the room, but the OS mic-in-use indicator // stays on while muted — same as other conferencing apps. Recording stops in // StopCapture (call end) or Dispose. @@ -167,11 +152,6 @@ public void Unpublish() _source?.Dispose(); _source = null; - - // The Android route override is likewise deliberately kept: the ADM continues - // playing remote audio while the mic is unpublished (listen-only / muted), and - // clearing the route here would drop that playout back onto the earpiece. - // Teardown happens in Dispose. } // Gates call audio on the ADM while the app keeps ownership of the audio session. @@ -213,13 +193,7 @@ bool InitializePlatformAudio() $"({_platformAudio.RecordingDeviceCount} mic(s), {_platformAudio.PlayoutDeviceCount} speaker(s))."); var (recording, playout) = _platformAudio.GetDevices(); - Debug.Log("[PlatformAudioController] Recording devices:"); - foreach (var device in recording) - Debug.Log($" [{device.Index}] {device.Name}"); - - Debug.Log("[PlatformAudioController] Playout devices:"); - foreach (var device in playout) - Debug.Log($" [{device.Index}] {device.Name}"); + Debug.Log(FormatDeviceLists(playout, recording)); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -237,333 +211,43 @@ bool InitializePlatformAudio() } } -#if UNITY_ANDROID && !UNITY_EDITOR - // Preference order for the voice-communication output route: - // Bluetooth > wired headset > built-in loudspeaker. The earpiece (and anything - // unrecognized) is never picked explicitly — when nothing ranked is available we - // leave the OS default in place, which on a phone IS the earpiece, so it naturally - // comes last. Note: Bluetooth devices only show up as communication devices if they - // support a voice profile (HFP/LE Audio); A2DP-only speakers can't carry call audio - // on Android and fall through to the loudspeaker. - static int RouteRank(int deviceType) - { - switch (deviceType) - { - case 26: // AudioDeviceInfo.TYPE_BLE_HEADSET - case 27: // AudioDeviceInfo.TYPE_BLE_SPEAKER - case 7: // AudioDeviceInfo.TYPE_BLUETOOTH_SCO - case 23: // AudioDeviceInfo.TYPE_HEARING_AID - return 0; - case 3: // AudioDeviceInfo.TYPE_WIRED_HEADSET - case 4: // AudioDeviceInfo.TYPE_WIRED_HEADPHONES - case 22: // AudioDeviceInfo.TYPE_USB_HEADSET - return 1; - case 2: // AudioDeviceInfo.TYPE_BUILTIN_SPEAKER - return 2; - default: - return int.MaxValue; - } - } - - static int AndroidSdkInt() - { - using var version = new AndroidJavaClass("android.os.Build$VERSION"); - return version.GetStatic("SDK_INT"); - } - - // Caller owns the returned object (wrap it in `using var`). - static AndroidJavaObject GetAudioManager() + // Demonstrates the SDK's routing observability: the routing backend raises + // DevicesChanged (on the Unity main thread) whenever the available devices or the + // active route change — headset plugged/unplugged, Bluetooth connected, the route + // re-pinned after a device disappeared. An app would refresh its device picker here. + static void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - return activity.Call("getSystemService", "audio"); + Debug.Log("[PlatformAudioController] Audio devices changed.\n" + + FormatDeviceLists(playout, recording)); } - // C#-side implementation of the Java callback interface. AndroidJavaProxy can only - // implement interfaces, which is why this listens for communication-device changes - // rather than subclassing android.media.AudioDeviceCallback (an abstract class). - sealed class CommunicationDeviceListener : AndroidJavaProxy + static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) { - public CommunicationDeviceListener() - : base("android.media.AudioManager$OnCommunicationDeviceChangedListener") { } - - // Invoked by Android on the activity's main executor — a JVM-attached thread, - // but NOT the Unity main thread: keep the body restricted to JNI and Debug.Log. - public void onCommunicationDeviceChanged(AndroidJavaObject device) + var sb = new StringBuilder("Playout devices:"); + foreach (var device in playout) { - int type = device != null ? device.Call("getType") : -1; - Debug.Log($"[PlatformAudioController] Communication device changed (type={type}); re-evaluating route."); - device?.Dispose(); - ApplyAndroidCommunicationRoute(); + sb.Append($"\n [{device.Index}] {device.Name} (kind={device.Kind}"); + if (device.IsSelected) + sb.Append(", selected"); + sb.Append(')'); } + sb.Append("\nRecording devices:"); + foreach (var device in recording) + sb.Append($"\n [{device.Index}] {device.Name}"); + return sb.ToString(); } - // Enters voice-communication audio mode, applies the preferred output route, and - // starts watching for route changes — all held until Dispose. Owning - // MODE_IN_COMMUNICATION is what makes the setCommunicationDevice pin authoritative: - // without it the platform periodically reasserts its own default route (observed on - // Pixel 8a: after a Bluetooth session ended, Telecom's CallAudioRouteController - // flipped playout back to the earpiece every ~6 s, endlessly fighting the re-pin). - // Side effects while the mode is held: hardware volume keys control the call stream, - // and Bluetooth audio runs over HFP/SCO (call quality) instead of A2DP — standard - // for call apps. - void SetupAndroidCommunicationAudio() - { - try - { - using var audioManager = GetAudioManager(); - _savedAudioMode = audioManager.Call("getMode"); - audioManager.Call("setMode", 3 /* AudioManager.MODE_IN_COMMUNICATION */); - Debug.Log($"[PlatformAudioController] Audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to enter communication mode: {e.Message}"); - } - - ApplyAndroidCommunicationRoute(); - RegisterAndroidRouteListener(); - } - - void TeardownAndroidCommunicationAudio() - { - // Unregister BEFORE clearing: clearCommunicationDevice fires the change event, - // and a still-registered listener would immediately re-pin the loudspeaker. - UnregisterAndroidRouteListener(); - ClearAndroidCommunicationRoute(); - - try - { - using var audioManager = GetAudioManager(); - audioManager.Call("setMode", _savedAudioMode); - Debug.Log($"[PlatformAudioController] Audio mode restored ({_savedAudioMode})."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to restore audio mode: {e.Message}"); - } - } - - // Re-evaluates the route whenever the OS changes the communication device — most - // importantly when the active device disconnects and playout would otherwise fall - // back to the earpiece. Registered for the whole session (Initialize until Dispose). - void RegisterAndroidRouteListener() - { - try - { - if (AndroidSdkInt() < 31) - return; - - using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); - using var activity = unityPlayer.GetStatic("currentActivity"); - using var audioManager = activity.Call("getSystemService", "audio"); - using var executor = activity.Call("getMainExecutor"); - - _routeListener = new CommunicationDeviceListener(); - audioManager.Call("addOnCommunicationDeviceChangedListener", executor, _routeListener); - Debug.Log("[PlatformAudioController] Registered communication device listener."); - } - catch (Exception e) - { - _routeListener = null; - Debug.LogWarning($"[PlatformAudioController] Failed to register device listener: {e.Message}"); - } - } - - void UnregisterAndroidRouteListener() - { - if (_routeListener == null) - return; - try - { - using var audioManager = GetAudioManager(); - audioManager.Call("removeOnCommunicationDeviceChangedListener", _routeListener); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to unregister device listener: {e.Message}"); - } - _routeListener = null; - } - - // Poll fallback for route changes that fire no communication-device event, run via - // StartCoroutine for the controller's whole lifetime. Device-verified gap on - // Pixel 8a (Android 16): when the Bluetooth headset powers off mid-call, SCO drops - // first and the communication device falls back to the earpiece while the headset - // is still in getAvailableCommunicationDevices — the listener's re-evaluation at - // that point still ranks the (dying) headset best. The headset leaves the device - // list up to ~10 s later WITHOUT another communication-device change (the device - // stays "earpiece"), so the listener never fires again and playout is stuck on the - // earpiece. Only a device-list diff catches that transition, and - // AudioDeviceCallback is an abstract class that AndroidJavaProxy cannot implement, - // hence polling. Also covers devices ADDED while a pin is active, which equally - // fires no event. - public IEnumerator AndroidRouteWatchdog() - { - var interval = new WaitForSeconds(1.5f); - while (IsInitialized) - { - if (AndroidRouteNeedsReapply()) - { - Debug.Log("[PlatformAudioController] Route watchdog detected divergence; re-evaluating."); - ApplyAndroidCommunicationRoute(); - } - yield return interval; - } - } - - // True when a strictly better-ranked communication device is available than the - // one currently active — the pinned device vanished and the OS fell back to the - // earpiece, or a better device appeared without an event. Rank (not id) comparison - // on purpose: a headset can expose several same-rank entries (BLE + SCO) and which - // of those the OS activates is its call, not a divergence to correct. Kept - // separate from ApplyAndroidCommunicationRoute so the quiescent poll stays two - // JNI queries with no logging. - static bool AndroidRouteNeedsReapply() - { - try - { - if (AndroidSdkInt() < 31) - return false; - - using var audioManager = GetAudioManager(); - using var current = audioManager.Call("getCommunicationDevice"); - int currentRank = current != null ? RouteRank(current.Call("getType")) : int.MaxValue; - - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) - { - using var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - bestRank = rank; - } - return bestRank < currentRank; - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Route watchdog check failed: {e.Message}"); - return false; - } - } - - // On Android the native ADM leaves output routing to the OS (SetPlayoutDevice is a - // documented no-op there): remote tracks play through a voice-communication stream, - // whose default route is the earpiece. Pick the best route per RouteRank via - // AudioManager — setCommunicationDevice on Android 12+ (API 31), where - // setSpeakerphoneOn is deprecated and unreliable, setSpeakerphoneOn below. - // The route is session-scoped: applied in Initialize, re-evaluated on each Publish - // and on every OS communication-device change (see RegisterAndroidRouteListener), - // and cleared in Dispose. Re-pinning is skipped when the best-ranked device is - // already active — our own setCommunicationDevice fires the change listener, and - // the no-op check is what stops that feedback loop. The pin only holds while the - // app owns MODE_IN_COMMUNICATION — see SetupAndroidCommunicationAudio. - static void ApplyAndroidCommunicationRoute() - { - try - { - using var audioManager = GetAudioManager(); - - if (AndroidSdkInt() >= 31) - { - using var current = audioManager.Call("getCommunicationDevice"); - int currentId = current != null ? current.Call("getId") : -1; - - using var devices = audioManager.Call("getAvailableCommunicationDevices"); - int count = devices.Call("size"); - AndroidJavaObject best = null; - int bestRank = int.MaxValue; - for (int i = 0; i < count; i++) - { - var device = devices.Call("get", i); - int rank = RouteRank(device.Call("getType")); - if (rank < bestRank) - { - best?.Dispose(); - best = device; - bestRank = rank; - } - else - { - device.Dispose(); - } - } - - if (best != null) - { - if (best.Call("getId") == currentId) - { - Debug.Log($"[PlatformAudioController] Best route (type={best.Call("getType")}) already active; skipping re-pin."); - } - else - { - bool ok = audioManager.Call("setCommunicationDevice", best); - Debug.Log($"[PlatformAudioController] setCommunicationDevice(type={best.Call("getType")}) -> {ok}"); - } - best.Dispose(); - } - else - { - Debug.LogWarning("[PlatformAudioController] No ranked communication device available; leaving default route."); - } - } - else - { - // Legacy path (pre-API-31). If a Bluetooth or wired output is attached, - // leave routing to the OS instead of hijacking it with the loudspeaker; - // proper legacy Bluetooth SCO management (startBluetoothSco) is out of - // scope for this demo. The AudioManager queries are deprecated but this - // branch only ever runs on old devices. - if (audioManager.Call("isBluetoothA2dpOn") - || audioManager.Call("isBluetoothScoOn") - || audioManager.Call("isWiredHeadsetOn")) - { - Debug.Log("[PlatformAudioController] External output attached; leaving OS routing."); - return; - } - audioManager.Call("setSpeakerphoneOn", true); - Debug.Log("[PlatformAudioController] setSpeakerphoneOn(true)"); - } - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to set communication route: {e.Message}"); - } - } - - // Hands output routing back to the OS default. Only called from Dispose: the route - // is session-scoped on purpose (see Unpublish). - static void ClearAndroidCommunicationRoute() - { - try - { - using var audioManager = GetAudioManager(); - if (AndroidSdkInt() >= 31) - audioManager.Call("clearCommunicationDevice"); - else - audioManager.Call("setSpeakerphoneOn", false); - Debug.Log("[PlatformAudioController] Restored default audio route."); - } - catch (Exception e) - { - Debug.LogWarning($"[PlatformAudioController] Failed to clear communication route: {e.Message}"); - } - } -#endif - public void Dispose() { Unpublish(); StopCapture(); - _platformAudio?.Dispose(); - _platformAudio = null; - -#if UNITY_ANDROID && !UNITY_EDITOR - TeardownAndroidCommunicationAudio(); -#endif + if (_platformAudio != null) + { + _platformAudio.DevicesChanged -= OnDevicesChanged; + _platformAudio.Dispose(); + _platformAudio = null; + } _room = null; } From 90eac90e78f24ab8bf5313dd8c9871a44dcf1e62 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:05:41 +0200 Subject: [PATCH 17/35] Document the audio output routing API in the README Adds an "Audio Output Routing" section to the platform-audio docs: the default OutputPreference order, the IsSpeakerOutputPreferred convenience toggle, sticky SelectOutput/ClearOutputOverride, DevicesChanged, and per-platform behavior (Android 12+/older Android/iOS/desktop), including the MODIFY_AUDIO_SETTINGS requirement and the Android 13 active-capture constraint. The Meet sample README points at it. Co-Authored-By: Claude Fable 5 --- README.md | 32 ++++++++++++++++++++++++++++++++ Samples~/Meet/README.md | 2 ++ 2 files changed, 34 insertions(+) diff --git a/README.md b/README.md index 6a0ff028..f398394c 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,38 @@ 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 */ }; +``` + +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. `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`. 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). +- **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. diff --git a/Samples~/Meet/README.md b/Samples~/Meet/README.md index 864d185b..0a97bf63 100644 --- a/Samples~/Meet/README.md +++ b/Samples~/Meet/README.md @@ -18,6 +18,8 @@ In order to connect to your LiveKit server, configure the token source component The LiveKit Unity SDK offers two audio systems. The Unity audio path uses the Unity APIs for audio input and output. Platform Audio is the alternative, where the native LiveKit plugin manages audio input and output. You can select which path to use on the MeetManager component. +With Platform Audio, output routing (Bluetooth/wired headset/speaker/earpiece on mobile) is handled by the SDK's `PlatformAudio.OutputPreference` policy — the sample contains no routing code of its own and only logs the SDK's `DevicesChanged` events (see `PlatformAudioController`). The audio-routing section of the [SDK README](https://github.com/livekit/client-sdk-unity#audio-output-routing) documents the API and per-platform behavior. + ### Common sample package In order to get access to common sample functions like the on device scrolling log, make sure to import the [Common](https://github.com/livekit/client-sdk-unity/tree/main/Samples~/Common) sample from the LiveKit Unity Package in the package manager. \ No newline at end of file From e9cde4b06e94c29e8ade97a008e8db8b319c101c Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:21:07 +0200 Subject: [PATCH 18/35] Adding forgotten meta file --- Samples~/Agents/Assets/Build.meta | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 Samples~/Agents/Assets/Build.meta diff --git a/Samples~/Agents/Assets/Build.meta b/Samples~/Agents/Assets/Build.meta new file mode 100644 index 00000000..b3c9e97a --- /dev/null +++ b/Samples~/Agents/Assets/Build.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b0d7497a6950e4f02aa4b812de50fd9f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 6f98d842eeaa1718bed0dbdae32191f293702f4e Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:45:06 +0200 Subject: [PATCH 19/35] Gate the Android call audio session on SetSessionAudioEnabled (PAR-023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android routing backend took MODE_IN_COMMUNICATION and pinned the output route in its constructor and held both until Dispose, so an app that creates PlatformAudio at startup to keep one ADM alive sat in call mode from launch to quit. SetSessionAudioEnabled — the switch iOS already uses for this — was a documented no-op on Android. It is now plumbed through the routing backends: the Android backend takes the session on enable and hands it back on disable (pin cleared, replaced mode restored per transition, never an unconditional MODE_NORMAL), while enumeration, the change listener and the poll thread stay alive in both states so GetDevices and DevicesChanged keep working while idle. Every re-evaluation path — listener, poll, and the StartRecording re-assert — is observation-only while disabled, so none of them can resurrect a released session. The documented default (enabled at creation, uniform with iOS) is unchanged; the samples disable it right after creating PlatformAudio and MeetManager re-enables it for the duration of a call. The samples also recover Unity's own audio across output route changes: it does not follow a route change on its own, so the controller reopens the audio engine with AudioSettings.Reset, driven by Unity's OnAudioConfigurationChanged and by the SDK's DevicesChanged (logging both, since which one Android delivers is device behavior), coalesced so one route change resets once. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 ++- .../Scripts/Audio/AndroidRouteController.cs | 126 +++++++++++++--- Runtime/Scripts/Audio/IosRouteController.cs | 7 + Runtime/Scripts/Audio/PlatformAudio.cs | 43 ++++-- Runtime/Scripts/Audio/RouteController.cs | 17 +++ .../Runtime/Agent/PlatformAudioController.cs | 135 ++++++++++++++++-- .../Assets/Runtime/PlatformAudioController.cs | 135 ++++++++++++++++-- 7 files changed, 432 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index f398394c..a2eee934 100644 --- a/README.md +++ b/README.md @@ -440,9 +440,30 @@ platformAudio.ClearOutputOverride(); 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 holds neither `MODE_IN_COMMUNICATION` nor the output route pin, so the platform's normal routing applies. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call keeps the phone in call mode for as long as the instance lives, which on Android also keeps a classic-Bluetooth headset on a call link instead of A2DP media playback. + +Unity's own audio engine is a separate layer that the SDK does not touch: it opens an output device when the app starts and does not follow a later output route change on its own. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when the route moves — `DevicesChanged` is a signal for that; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. + 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. `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`. 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). +- **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. `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 SDK enters `MODE_IN_COMMUNICATION` and pins the route on enable, 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). diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 20914d0f..077e4bdb 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -14,18 +14,27 @@ namespace LiveKit /// AudioManager.getAvailableCommunicationDevices / /// setCommunicationDevice / clearCommunicationDevice. /// - /// The controller owns the voice-communication audio session for its whole lifetime - /// (construction to ): it enters MODE_IN_COMMUNICATION - /// (saving and restoring the prior mode) and keeps the output route pinned to the - /// best device — the sticky override while its device is - /// still available, otherwise the highest-ranked available kind per the current + /// The controller owns the voice-communication audio session while session audio is + /// enabled ( — i.e. while a call is in progress): + /// it enters MODE_IN_COMMUNICATION (saving and restoring the prior mode) and + /// keeps the output route pinned to the best device — the sticky + /// override while its device is still available, otherwise + /// the highest-ranked available kind per the current /// . Owning the mode is what makes the /// pin authoritative: without it the platform periodically reasserts its own default /// route (observed on Pixel 8a: Telecom flipped playout back to the earpiece every /// ~6 s after a Bluetooth session ended). Note that since Android 13 the mode request /// is only honored while the app has active voice-communication capture, so /// re-asserts the policy when capture - /// (re)starts. + /// (re)starts — through , which like every other + /// re-evaluation path pins nothing while session audio is disabled. + /// + /// While session audio is disabled the session is handed back to the platform + /// (communication device cleared, prior mode restored) so the phone is not held in + /// call mode outside a call — on a classic-Bluetooth headset that is the difference + /// between an HFP call link and A2DP media playback. Enumeration, the change listener + /// and the poll thread stay alive regardless, so and + /// keep reporting the platform's own routing while idle. /// /// Route changes are detected two ways, both required (device-verified in the /// sample hotfix this backend is hardened from, PR #364): @@ -63,7 +72,11 @@ internal sealed class AndroidRouteController : IRouteController private List _ranked; private int _stickyDeviceId = -1; private int _pinnedDeviceId = -1; + // Session audio starts enabled, matching the documented default of + // PlatformAudio.SetSessionAudioEnabled (uniform with iOS). + private bool _sessionAudioEnabled = true; private int _savedAudioMode; + private bool _audioModeSaved; private CommunicationDeviceListener _listener; private AndroidJavaObject _audioFocusRequest; private bool _audioFocusEnabled; @@ -108,6 +121,9 @@ private AndroidRouteController(PlatformAudio owner, IReadOnlyList + /// Takes or hands back the call audio session: enabling enters + /// MODE_IN_COMMUNICATION and lets the policy pin the route, disabling + /// clears the pin and restores the mode this controller replaced. The sticky + /// override and the ranked preference survive the transition, so a call that + /// re-enables the session routes exactly as it did before. + /// + public void SetSessionAudioEnabled(bool enabled) + { + lock (_gate) + { + if (_disposed || _sessionAudioEnabled == enabled) + return; + _sessionAudioEnabled = enabled; + if (enabled) + EnterCommunicationMode(); + else + LeaveCommunicationMode(); + } + + // Re-evaluate outside the lock (Reevaluate takes it): pin the policy's target + // on enable, report the platform's own route on disable. + Reevaluate(); + } + /// /// Optional audio-focus request (AUDIOFOCUS_GAIN with voice-communication /// attributes) held while enabled. Off by default. Not exposed on the public @@ -230,18 +271,11 @@ public void Dispose() lock (_gate) { AbandonAudioFocus(); - } - - try - { - using var audioManager = GetAudioManager(); - audioManager.Call("clearCommunicationDevice"); - audioManager.Call("setMode", _savedAudioMode); - Utils.Debug($"AndroidRouteController: route cleared, audio mode restored ({_savedAudioMode})"); - } - catch (Exception e) - { - Utils.Warning($"AndroidRouteController: failed to restore audio session: {e.Message}"); + // Same idempotent release as a session-audio disable: clearing a pin we + // no longer hold is a no-op, and the mode is only restored when this + // controller is the one that replaced it. + LeaveCommunicationMode(); + _sessionAudioEnabled = false; } } @@ -255,6 +289,14 @@ public void Dispose() /// and that no-op check is what stops the feedback loop. When nothing sticky or /// ranked is available, an existing pin is released so the OS default applies; /// kinds missing from the ranking are never auto-selected. + /// + /// While session audio is disabled the pass is observation-only: it enumerates, + /// keeps the sticky bookkeeping current and still raises + /// , but issues no setCommunicationDevice / + /// clearCommunicationDevice and reports the platform's own communication device + /// as the selected one. Every trigger — the change listener, the poll thread and + /// the re-assert — runs through here, + /// so none of them can resurrect a released session. /// private void Reevaluate() { @@ -306,7 +348,17 @@ private void Reevaluate() } int selectedId; - if (targetIndex >= 0) + if (!_sessionAudioEnabled) + { + // No call in progress: report which device the platform would + // use for communication audio, and touch nothing. The target + // computed above is still worth running — it keeps the sticky + // override's "dropped once the device disappears" bookkeeping + // alive while idle — but it is only applied once a call + // re-enables the session. + selectedId = currentId; + } + else if (targetIndex >= 0) { var target = devices[targetIndex]; if (target.Id != currentId) @@ -397,12 +449,22 @@ private void PollLoop() } } + // Both mode methods are called under _gate. The save/restore pairs up per + // enable -> disable transition and is idempotent in both directions: the prior + // mode is only captured when we do not already hold one, and it is only restored + // when it was actually read from the platform — a failed read must never turn + // into an unconditional MODE_NORMAL, which would stomp a mode this app does not + // own (the rule the PAR-000 hotfix established). private void EnterCommunicationMode() { try { using var audioManager = GetAudioManager(); - _savedAudioMode = audioManager.Call("getMode"); + if (!_audioModeSaved) + { + _savedAudioMode = audioManager.Call("getMode"); + _audioModeSaved = true; + } audioManager.Call("setMode", ModeInCommunication); Utils.Debug($"AndroidRouteController: audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})"); } @@ -412,6 +474,30 @@ private void EnterCommunicationMode() } } + private void LeaveCommunicationMode() + { + try + { + using var audioManager = GetAudioManager(); + audioManager.Call("clearCommunicationDevice"); + _pinnedDeviceId = -1; + if (_audioModeSaved) + { + audioManager.Call("setMode", _savedAudioMode); + _audioModeSaved = false; + Utils.Debug($"AndroidRouteController: route cleared, audio mode restored ({_savedAudioMode})"); + } + else + { + Utils.Debug("AndroidRouteController: route cleared, no saved audio mode to restore"); + } + } + catch (Exception e) + { + Utils.Warning($"AndroidRouteController: failed to release the audio session: {e.Message}"); + } + } + private void RegisterListener() { try diff --git a/Runtime/Scripts/Audio/IosRouteController.cs b/Runtime/Scripts/Audio/IosRouteController.cs index 7dc5ef08..2fc9ff2c 100644 --- a/Runtime/Scripts/Audio/IosRouteController.cs +++ b/Runtime/Scripts/Audio/IosRouteController.cs @@ -108,6 +108,13 @@ public void ClearOutputOverride() // No override can exist on iOS: SelectOutput throws. } + public void SetSessionAudioEnabled(bool enabled) + { + // Handled by PlatformAudio itself on iOS: it drives the session state machine + // (which needs the recording state this backend does not know) through + // IOSAudioSessionHelper. Nothing route-specific to gate here. + } + public void Dispose() { lock (StaticGate) diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index aa030f81..c91f1916 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -192,6 +192,13 @@ private void UpdateIosSessionState() /// is active, and a music-friendly default mode otherwise (see /// / / /// ). + /// + /// Session audio starts out enabled on every platform, so creating an instance + /// takes the platform's call audio session — on Android 12 (API 31) and newer + /// that means MODE_IN_COMMUNICATION plus the output route pin. Apps that + /// create PlatformAudio before their first call should call + /// with false right after + /// construction and enable it when a call starts. /// /// /// Thrown if the platform ADM could not be initialized (e.g., no audio devices, @@ -737,18 +744,35 @@ public void StopRecording() } /// - /// Signals whether call audio should be active on the platform audio session. + /// Signals whether call audio should be active on the platform audio session, + /// i.e. whether a call is in progress. Enabled by default when PlatformAudio is + /// created, so it only needs to be called to false when leaving a room + /// (and back to true when rejoining) — but an app that creates + /// PlatformAudio well before its first call (e.g. at startup, to keep the ADM + /// alive) should disable it right after creation, so the platform's call audio + /// session is only held for the duration of an actual call. /// /// On iOS this gates WebRTC's VPIO audio unit while the app retains ownership - /// of the shared AVAudioSession. It is enabled by default when PlatformAudio is - /// created, so this only needs to be called to false when leaving a room - /// (and back to true when rejoining). Disabling stops the microphone/ - /// remote audio path and the hardware voice processing, and drops the session - /// to its idle state (music-friendly default mode), but keeps the audio - /// session active so other Unity audio (e.g. background music) is not - /// interrupted — which is why Unity audio survives a hang-up. + /// of the shared AVAudioSession. Disabling stops the microphone/remote audio + /// path and the hardware voice processing, and drops the session to its idle + /// state (music-friendly default mode), but keeps the audio session active so + /// other Unity audio (e.g. background music) is not interrupted — which is why + /// Unity audio survives a hang-up. + /// + /// On Android 12 (API 31) and newer this gates the voice-communication audio + /// session the routing backend holds: while enabled the SDK owns + /// MODE_IN_COMMUNICATION and keeps the output route pinned per + /// ; while disabled it holds neither, so the OS + /// returns to its normal routing (and a Bluetooth headset stays on A2DP media + /// instead of an HFP call link). Device enumeration and + /// keep working while disabled. Unlike iOS, + /// disabling does not stop the ADM: pair it with + /// / at the call + /// boundaries — an active capture without the session is what lets the platform + /// take routing back (see ). /// - /// On other platforms this is a no-op: the OS/ADM manages the session directly. + /// On the remaining platforms this is a no-op: the OS/ADM manages the session + /// directly. /// /// True while a call is active, false otherwise. public void SetSessionAudioEnabled(bool enabled) @@ -758,6 +782,7 @@ public void SetSessionAudioEnabled(bool enabled) _iosSessionAudioEnabled = enabled; UpdateIosSessionState(); #endif + _routeController.SetSessionAudioEnabled(enabled); Utils.Debug($"PlatformAudio: session audio enabled={enabled}"); } diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs index 1149b2e8..aef51735 100644 --- a/Runtime/Scripts/Audio/RouteController.cs +++ b/Runtime/Scripts/Audio/RouteController.cs @@ -29,6 +29,13 @@ internal interface IRouteController : IDisposable /// Clears the sticky override so the automatic policy applies again. void ClearOutputOverride(); + /// + /// Signals whether a call is in progress, i.e. whether the backend may hold the + /// platform's voice-communication audio session. Device enumeration and + /// must keep working while disabled. + /// + void SetSessionAudioEnabled(bool enabled); + /// /// Raised when the available devices change, with the current (playout, recording) /// lists. May be raised from any thread; marshals it to @@ -75,6 +82,11 @@ public void ClearOutputOverride() // No automatic policy to fall back to on desktop; the selected device stays. } + public void SetSessionAudioEnabled(bool enabled) + { + // No call session to hold on desktop: the ADM owns the devices directly. + } + public event Action, IReadOnlyList> DevicesChanged { add { } @@ -125,6 +137,11 @@ public void ClearOutputOverride() // No override can exist on this platform: SelectOutput throws. } + public void SetSessionAudioEnabled(bool enabled) + { + // Nothing to gate: this platform has no routing backend holding a session. + } + public event Action, IReadOnlyList> DevicesChanged { add { } diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 2daf215e..651c02f2 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -14,15 +14,24 @@ // // Output routing is owned by the SDK: PlatformAudio routes to the best available output // per its ranked OutputPreference (default: Bluetooth > wired headset > speaker > -// earpiece) and keeps the route pinned across device changes for its whole lifetime. -// This controller only demonstrates the observability side by logging DevicesChanged. -// On Android an active mic capture is what keeps the SDK's route authoritative (since -// Android 13 the OS only honors an app's communication-mode request while it has active -// voice-communication capture), so start the capture with StartCapture when the call -// begins (even when joining muted) — it then stays open across mute cycles until -// StopCapture when the call ends. See StartCapture and Unpublish. +// earpiece) and keeps the route pinned across device changes while a call is in +// progress. This controller only demonstrates the observability side by logging +// DevicesChanged. On Android an active mic capture is what keeps the SDK's route +// authoritative (since Android 13 the OS only honors an app's communication-mode request +// while it has active voice-communication capture), so start the capture with +// StartCapture when the call begins (even when joining muted) — it then stays open +// across mute cycles until StopCapture when the call ends. See StartCapture and +// Unpublish. +// +// The call audio session itself is gated by SetSessionAudioEnabled: the ADM is created +// once at app start and kept alive, but the platform's call session is only held for the +// duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { + // Long enough to swallow the second signal for the same route change: the SDK's + // Android poll can trail Unity's own notification by up to ~1.5 s. + const float UnityAudioResetCoalesceSeconds = 2f; + readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -31,6 +40,8 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; + string _selectedOutput; + float _lastUnityAudioReset = float.NegativeInfinity; public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -54,6 +65,16 @@ public bool Initialize() // A custom ranking would be a one-liner: // _platformAudio.OutputPreference = new[] { AudioOutputKind.WiredHeadset, AudioOutputKind.Speaker }; _platformAudio.DevicesChanged += OnDevicesChanged; + AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; + + // Session audio is enabled when PlatformAudio is created, so hand it straight + // back: this controller is created at app start (to keep one ADM alive for every + // call), while the platform's call audio session should only be held while a call + // is actually in progress — enabled means "in a call". Without this the phone + // sits in communication mode from launch to quit, which on Android keeps a + // Bluetooth headset on a call link (HFP) instead of A2DP media the whole time. + // MeetManager re-enables it on join and disables it again on leave. + _platformAudio.SetSessionAudioEnabled(false); return true; } @@ -154,10 +175,12 @@ public void Unpublish() _source = null; } - // Gates call audio on the ADM while the app keeps ownership of the audio session. - // On iOS this switches WebRTC's VPIO unit on/off so other Unity audio (e.g. - // background music) survives leaving a room; on other platforms it is a no-op. - // Call with true after joining a room and false when leaving it. + // Gates the platform's call audio session: enabled means a call is in progress. + // On iOS it switches WebRTC's VPIO unit on/off while the app keeps ownership of the + // audio session, on Android 12+ it takes and releases the communication mode plus + // the SDK's output route pin — both so other Unity audio (e.g. background music) + // keeps playing outside a call. Call with true after joining a room and false when + // leaving it; Initialize() already disabled it for the idle app. public void SetSessionAudioEnabled(bool enabled) { _platformAudio?.SetSessionAudioEnabled(enabled); @@ -194,6 +217,8 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); + // Baseline for the route-change recovery: only a change from here on is one. + _selectedOutput = SelectedOutputKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -215,10 +240,95 @@ bool InitializePlatformAudio() // DevicesChanged (on the Unity main thread) whenever the available devices or the // active route change — headset plugged/unplugged, Bluetooth connected, the route // re-pinned after a device disappeared. An app would refresh its device picker here. - static void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) + // This sample also uses it to bring Unity's own audio back onto the new route (see + // ResetUnityAudioOutput). + void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { Debug.Log("[PlatformAudioController] Audio devices changed.\n" + FormatDeviceLists(playout, recording)); + + // Recover Unity audio when the active route moved. When the platform names no + // active route (nothing IsSelected, possible outside a call), this event still + // means the reachable devices changed — the best available signal that the output + // moved, so recover rather than guess. + var selected = SelectedOutputKey(playout); + var routeMoved = selected == null || selected != _selectedOutput; + _selectedOutput = selected; + if (routeMoved) + ResetUnityAudioOutput("SDK route change"); + } + + // Unity's audio engine opens an output device when the app starts and keeps writing + // to it: when the OS moves the output route (Bluetooth connect/disconnect, wired + // plug/unplug), game audio does not follow it and does not recover on its own. + // Reopening the engine with AudioSettings.Reset is the fix, and it is deliberately + // the app's call rather than the SDK's — it stops every AudioSource in the scene. + // + // Two signals can drive it and this sample listens to both, because which of them + // the platform actually delivers is worth observing rather than assuming: + // - AudioSettings.OnAudioConfigurationChanged(deviceWasChanged: true), Unity's own + // notification (logged here, so a device run shows whether it fires at all), and + // - PlatformAudio.DevicesChanged, the SDK's routing event — which is why the + // routing API exposes the active route, not just the device list. + // Whichever arrives first triggers the reset; ResetUnityAudioOutput coalesces the + // other one. + void OnUnityAudioConfigurationChanged(bool deviceWasChanged) + { + Debug.Log("[PlatformAudioController] Unity audio configuration changed " + + $"(deviceWasChanged={deviceWasChanged}, outputSampleRate={AudioSettings.outputSampleRate}, " + + $"speakerMode={AudioSettings.speakerMode})."); + + // Reset itself raises this callback with deviceWasChanged false, so only a real + // device change re-enters the recovery. + if (deviceWasChanged) + ResetUnityAudioOutput("Unity device change"); + } + + void ResetUnityAudioOutput(string reason) + { + // Both callers run on the Unity main thread (the SDK marshals DevicesChanged + // there), so the Unity APIs below are safe to touch. + var now = Time.realtimeSinceStartup; + if (now - _lastUnityAudioReset < UnityAudioResetCoalesceSeconds) + { + Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " + + $"ago, skipping ({reason})."); + return; + } + _lastUnityAudioReset = now; + + // Reset stops every AudioSource, so resume the ones that were playing; an app + // would restart its own music/SFX here instead of sweeping the scene. + var playing = new List(); + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + if (source.isPlaying) + playing.Add(source); + + Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " + + $"resuming {playing.Count} source(s)."); + + if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) + { + Debug.LogWarning("[PlatformAudioController] AudioSettings.Reset failed; Unity audio may stay silent."); + return; + } + + foreach (var source in playing) + { + if (source == null) continue; + source.Stop(); + source.Play(); + } + } + + // Identifies the active output route, or null when the platform reports none — which + // can happen outside a call, where the SDK holds no route of its own. + static string SelectedOutputKey(IReadOnlyList playout) + { + foreach (var device in playout) + if (device.IsSelected) + return string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid; + return null; } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) @@ -244,6 +354,7 @@ public void Dispose() if (_platformAudio != null) { + AudioSettings.OnAudioConfigurationChanged -= OnUnityAudioConfigurationChanged; _platformAudio.DevicesChanged -= OnDevicesChanged; _platformAudio.Dispose(); _platformAudio = null; diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 2daf215e..651c02f2 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -14,15 +14,24 @@ // // Output routing is owned by the SDK: PlatformAudio routes to the best available output // per its ranked OutputPreference (default: Bluetooth > wired headset > speaker > -// earpiece) and keeps the route pinned across device changes for its whole lifetime. -// This controller only demonstrates the observability side by logging DevicesChanged. -// On Android an active mic capture is what keeps the SDK's route authoritative (since -// Android 13 the OS only honors an app's communication-mode request while it has active -// voice-communication capture), so start the capture with StartCapture when the call -// begins (even when joining muted) — it then stays open across mute cycles until -// StopCapture when the call ends. See StartCapture and Unpublish. +// earpiece) and keeps the route pinned across device changes while a call is in +// progress. This controller only demonstrates the observability side by logging +// DevicesChanged. On Android an active mic capture is what keeps the SDK's route +// authoritative (since Android 13 the OS only honors an app's communication-mode request +// while it has active voice-communication capture), so start the capture with +// StartCapture when the call begins (even when joining muted) — it then stays open +// across mute cycles until StopCapture when the call ends. See StartCapture and +// Unpublish. +// +// The call audio session itself is gated by SetSessionAudioEnabled: the ADM is created +// once at app start and kept alive, but the platform's call session is only held for the +// duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { + // Long enough to swallow the second signal for the same route change: the SDK's + // Android poll can trail Unity's own notification by up to ~1.5 s. + const float UnityAudioResetCoalesceSeconds = 2f; + readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -31,6 +40,8 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; + string _selectedOutput; + float _lastUnityAudioReset = float.NegativeInfinity; public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -54,6 +65,16 @@ public bool Initialize() // A custom ranking would be a one-liner: // _platformAudio.OutputPreference = new[] { AudioOutputKind.WiredHeadset, AudioOutputKind.Speaker }; _platformAudio.DevicesChanged += OnDevicesChanged; + AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; + + // Session audio is enabled when PlatformAudio is created, so hand it straight + // back: this controller is created at app start (to keep one ADM alive for every + // call), while the platform's call audio session should only be held while a call + // is actually in progress — enabled means "in a call". Without this the phone + // sits in communication mode from launch to quit, which on Android keeps a + // Bluetooth headset on a call link (HFP) instead of A2DP media the whole time. + // MeetManager re-enables it on join and disables it again on leave. + _platformAudio.SetSessionAudioEnabled(false); return true; } @@ -154,10 +175,12 @@ public void Unpublish() _source = null; } - // Gates call audio on the ADM while the app keeps ownership of the audio session. - // On iOS this switches WebRTC's VPIO unit on/off so other Unity audio (e.g. - // background music) survives leaving a room; on other platforms it is a no-op. - // Call with true after joining a room and false when leaving it. + // Gates the platform's call audio session: enabled means a call is in progress. + // On iOS it switches WebRTC's VPIO unit on/off while the app keeps ownership of the + // audio session, on Android 12+ it takes and releases the communication mode plus + // the SDK's output route pin — both so other Unity audio (e.g. background music) + // keeps playing outside a call. Call with true after joining a room and false when + // leaving it; Initialize() already disabled it for the idle app. public void SetSessionAudioEnabled(bool enabled) { _platformAudio?.SetSessionAudioEnabled(enabled); @@ -194,6 +217,8 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); + // Baseline for the route-change recovery: only a change from here on is one. + _selectedOutput = SelectedOutputKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -215,10 +240,95 @@ bool InitializePlatformAudio() // DevicesChanged (on the Unity main thread) whenever the available devices or the // active route change — headset plugged/unplugged, Bluetooth connected, the route // re-pinned after a device disappeared. An app would refresh its device picker here. - static void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) + // This sample also uses it to bring Unity's own audio back onto the new route (see + // ResetUnityAudioOutput). + void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { Debug.Log("[PlatformAudioController] Audio devices changed.\n" + FormatDeviceLists(playout, recording)); + + // Recover Unity audio when the active route moved. When the platform names no + // active route (nothing IsSelected, possible outside a call), this event still + // means the reachable devices changed — the best available signal that the output + // moved, so recover rather than guess. + var selected = SelectedOutputKey(playout); + var routeMoved = selected == null || selected != _selectedOutput; + _selectedOutput = selected; + if (routeMoved) + ResetUnityAudioOutput("SDK route change"); + } + + // Unity's audio engine opens an output device when the app starts and keeps writing + // to it: when the OS moves the output route (Bluetooth connect/disconnect, wired + // plug/unplug), game audio does not follow it and does not recover on its own. + // Reopening the engine with AudioSettings.Reset is the fix, and it is deliberately + // the app's call rather than the SDK's — it stops every AudioSource in the scene. + // + // Two signals can drive it and this sample listens to both, because which of them + // the platform actually delivers is worth observing rather than assuming: + // - AudioSettings.OnAudioConfigurationChanged(deviceWasChanged: true), Unity's own + // notification (logged here, so a device run shows whether it fires at all), and + // - PlatformAudio.DevicesChanged, the SDK's routing event — which is why the + // routing API exposes the active route, not just the device list. + // Whichever arrives first triggers the reset; ResetUnityAudioOutput coalesces the + // other one. + void OnUnityAudioConfigurationChanged(bool deviceWasChanged) + { + Debug.Log("[PlatformAudioController] Unity audio configuration changed " + + $"(deviceWasChanged={deviceWasChanged}, outputSampleRate={AudioSettings.outputSampleRate}, " + + $"speakerMode={AudioSettings.speakerMode})."); + + // Reset itself raises this callback with deviceWasChanged false, so only a real + // device change re-enters the recovery. + if (deviceWasChanged) + ResetUnityAudioOutput("Unity device change"); + } + + void ResetUnityAudioOutput(string reason) + { + // Both callers run on the Unity main thread (the SDK marshals DevicesChanged + // there), so the Unity APIs below are safe to touch. + var now = Time.realtimeSinceStartup; + if (now - _lastUnityAudioReset < UnityAudioResetCoalesceSeconds) + { + Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " + + $"ago, skipping ({reason})."); + return; + } + _lastUnityAudioReset = now; + + // Reset stops every AudioSource, so resume the ones that were playing; an app + // would restart its own music/SFX here instead of sweeping the scene. + var playing = new List(); + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + if (source.isPlaying) + playing.Add(source); + + Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " + + $"resuming {playing.Count} source(s)."); + + if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) + { + Debug.LogWarning("[PlatformAudioController] AudioSettings.Reset failed; Unity audio may stay silent."); + return; + } + + foreach (var source in playing) + { + if (source == null) continue; + source.Stop(); + source.Play(); + } + } + + // Identifies the active output route, or null when the platform reports none — which + // can happen outside a call, where the SDK holds no route of its own. + static string SelectedOutputKey(IReadOnlyList playout) + { + foreach (var device in playout) + if (device.IsSelected) + return string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid; + return null; } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) @@ -244,6 +354,7 @@ public void Dispose() if (_platformAudio != null) { + AudioSettings.OnAudioConfigurationChanged -= OnUnityAudioConfigurationChanged; _platformAudio.DevicesChanged -= OnDevicesChanged; _platformAudio.Dispose(); _platformAudio = null; From 106413dbec51d91e95ec1e01af08efc9a9b248e2 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:00:26 +0200 Subject: [PATCH 20/35] Drop the unverified Bluetooth profile claims from the session docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device testing contradicted them: Unity audio kept playing over a Bluetooth headset both while idle and alongside an active call, so the docs must not assert that holding the call session forces a headset onto a call link instead of A2DP media. What the SDK actually does — request communication mode, pin the route, and release both when session audio is disabled — is stated instead; how a given device carries call and media audio concurrently is left to the device observation the card still owes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++-- Runtime/Scripts/Audio/AndroidRouteController.cs | 10 +++++----- Runtime/Scripts/Audio/PlatformAudio.cs | 6 +++--- .../Assets/Runtime/Agent/PlatformAudioController.cs | 5 ++--- .../Meet/Assets/Runtime/PlatformAudioController.cs | 5 ++--- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a2eee934..b1f2e7ce 100644 --- a/README.md +++ b/README.md @@ -457,9 +457,9 @@ 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 holds neither `MODE_IN_COMMUNICATION` nor the output route pin, so the platform's normal routing applies. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call keeps the phone in call mode for as long as the instance lives, which on Android also keeps a classic-Bluetooth headset on a call link instead of A2DP media playback. +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. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call asserts the call session 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: it opens an output device when the app starts and does not follow a later output route change on its own. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when the route moves — `DevicesChanged` is a signal for that; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. +Unity's own audio engine is a separate layer that the SDK does not touch: it opens an output device when the app starts and does not follow a later output route change on its own — a device that disconnects mid-playback leaves game audio writing into a route that moved. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when that happens — `DevicesChanged` is a signal for it; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. Per-platform behavior: diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 077e4bdb..9da5023a 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -30,11 +30,11 @@ namespace LiveKit /// re-evaluation path pins nothing while session audio is disabled. /// /// While session audio is disabled the session is handed back to the platform - /// (communication device cleared, prior mode restored) so the phone is not held in - /// call mode outside a call — on a classic-Bluetooth headset that is the difference - /// between an HFP call link and A2DP media playback. Enumeration, the change listener - /// and the poll thread stay alive regardless, so and - /// keep reporting the platform's own routing while idle. + /// (communication device cleared, prior mode restored), so the mode request and the + /// route pin cover the call rather than the lifetime of the instance. Enumeration, + /// the change listener and the poll thread stay alive regardless, so + /// and keep reporting the + /// platform's own routing while idle. /// /// Route changes are detected two ways, both required (device-verified in the /// sample hotfix this backend is hardened from, PR #364): diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index c91f1916..7f0c0397 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -760,11 +760,11 @@ public void StopRecording() /// Unity audio survives a hang-up. /// /// On Android 12 (API 31) and newer this gates the voice-communication audio - /// session the routing backend holds: while enabled the SDK owns + /// session the routing backend holds: while enabled the SDK requests /// MODE_IN_COMMUNICATION and keeps the output route pinned per /// ; while disabled it holds neither, so the OS - /// returns to its normal routing (and a Bluetooth headset stays on A2DP media - /// instead of an HFP call link). Device enumeration and + /// applies its normal routing and the call session covers the call rather than + /// the lifetime of this instance. Device enumeration and /// keep working while disabled. Unlike iOS, /// disabling does not stop the ADM: pair it with /// / at the call diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 651c02f2..be957f1d 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -70,9 +70,8 @@ public bool Initialize() // Session audio is enabled when PlatformAudio is created, so hand it straight // back: this controller is created at app start (to keep one ADM alive for every // call), while the platform's call audio session should only be held while a call - // is actually in progress — enabled means "in a call". Without this the phone - // sits in communication mode from launch to quit, which on Android keeps a - // Bluetooth headset on a call link (HFP) instead of A2DP media the whole time. + // is actually in progress — enabled means "in a call". Without this the app keeps + // requesting communication mode and pinning the call route from launch to quit. // MeetManager re-enables it on join and disables it again on leave. _platformAudio.SetSessionAudioEnabled(false); return true; diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 651c02f2..be957f1d 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -70,9 +70,8 @@ public bool Initialize() // Session audio is enabled when PlatformAudio is created, so hand it straight // back: this controller is created at app start (to keep one ADM alive for every // call), while the platform's call audio session should only be held while a call - // is actually in progress — enabled means "in a call". Without this the phone - // sits in communication mode from launch to quit, which on Android keeps a - // Bluetooth headset on a call link (HFP) instead of A2DP media the whole time. + // is actually in progress — enabled means "in a call". Without this the app keeps + // requesting communication mode and pinning the call route from launch to quit. // MeetManager re-enables it on join and disables it again on leave. _platformAudio.SetSessionAudioEnabled(false); return true; From 3a2145c91fc4e77f247544fb63c1f657522838e6 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:16:57 +0200 Subject: [PATCH 21/35] Recover Unity audio on device changes only, and document the SCO tradeoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device testing (Pixel 8a / Android 16, classic BR/EDR headset) established what actually breaks Unity's audio engine: a device being added or removed, not the route moving between the devices already connected. Joining a call leaves game audio playing, including when the platform moves media from the headset's A2DP link onto its call link. Resetting on every route change would therefore have restarted the game's audio at each join and hang-up for nothing, so the sample now triggers on the device set and resumes each source at the position it reached instead of from the start of the clip. The same run answered what the platform does with media during a call on a classic Bluetooth headset: dumpsys shows STREAM_MUSIC on bt_sco_hs while the call is active and back on bt_a2dp afterwards, with A2DP suspended for the duration — media is carried by the call link, not diverted or dropped. The README documents that as the observed behavior on that device, and as a further reason to hold the session only while a call is in progress. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +- .../Runtime/Agent/PlatformAudioController.cs | 61 +++++++++++-------- .../Assets/Runtime/PlatformAudioController.cs | 61 +++++++++++-------- 3 files changed, 71 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b1f2e7ce..af65302c 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,9 @@ 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. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call asserts the call session 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: it opens an output device when the app starts and does not follow a later output route change on its own — a device that disconnects mid-playback leaves game audio writing into a route that moved. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when that happens — `DevicesChanged` is a signal for it; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. +Unity's own audio engine is a separate layer that the SDK does not touch: it opens an output device when the app starts and does not follow a later change of that device on its own — connecting or disconnecting a headset leaves game audio writing into a device that is gone. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when that happens — `DevicesChanged` is a signal for it; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. + +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: diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index be957f1d..263ac191 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -40,7 +40,7 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - string _selectedOutput; + string _outputDevices; float _lastUnityAudioReset = float.NegativeInfinity; public bool IsInitialized => _platformAudio != null; @@ -216,8 +216,8 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); - // Baseline for the route-change recovery: only a change from here on is one. - _selectedOutput = SelectedOutputKey(playout); + // Baseline for the recovery: only a change from here on is a device change. + _outputDevices = OutputDeviceSetKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -246,22 +246,26 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList(); + // Reset stops every AudioSource, so remember what was playing and where, and + // pick each one up at the same position on the reopened engine; an app would + // restart its own music/SFX here instead of sweeping the scene. + var playing = new List<(AudioSource Source, float Time)>(); foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.isPlaying) - playing.Add(source); + playing.Add((source, source.time)); Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " + $"resuming {playing.Count} source(s)."); @@ -312,22 +317,24 @@ void ResetUnityAudioOutput(string reason) return; } - foreach (var source in playing) + foreach (var (source, time) in playing) { if (source == null) continue; source.Stop(); + source.time = time; source.Play(); } } - // Identifies the active output route, or null when the platform reports none — which - // can happen outside a call, where the SDK holds no route of its own. - static string SelectedOutputKey(IReadOnlyList playout) + // Identifies the set of connected output devices, ignoring which one is active and + // the order the platform enumerated them in. + static string OutputDeviceSetKey(IReadOnlyList playout) { + var keys = new List(playout.Count); foreach (var device in playout) - if (device.IsSelected) - return string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid; - return null; + keys.Add(string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid); + keys.Sort(StringComparer.Ordinal); + return string.Join("|", keys); } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index be957f1d..263ac191 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -40,7 +40,7 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - string _selectedOutput; + string _outputDevices; float _lastUnityAudioReset = float.NegativeInfinity; public bool IsInitialized => _platformAudio != null; @@ -216,8 +216,8 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); - // Baseline for the route-change recovery: only a change from here on is one. - _selectedOutput = SelectedOutputKey(playout); + // Baseline for the recovery: only a change from here on is a device change. + _outputDevices = OutputDeviceSetKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -246,22 +246,26 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList(); + // Reset stops every AudioSource, so remember what was playing and where, and + // pick each one up at the same position on the reopened engine; an app would + // restart its own music/SFX here instead of sweeping the scene. + var playing = new List<(AudioSource Source, float Time)>(); foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.isPlaying) - playing.Add(source); + playing.Add((source, source.time)); Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " + $"resuming {playing.Count} source(s)."); @@ -312,22 +317,24 @@ void ResetUnityAudioOutput(string reason) return; } - foreach (var source in playing) + foreach (var (source, time) in playing) { if (source == null) continue; source.Stop(); + source.time = time; source.Play(); } } - // Identifies the active output route, or null when the platform reports none — which - // can happen outside a call, where the SDK holds no route of its own. - static string SelectedOutputKey(IReadOnlyList playout) + // Identifies the set of connected output devices, ignoring which one is active and + // the order the platform enumerated them in. + static string OutputDeviceSetKey(IReadOnlyList playout) { + var keys = new List(playout.Count); foreach (var device in playout) - if (device.IsSelected) - return string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid; - return null; + keys.Add(string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid); + keys.Sort(StringComparer.Ordinal); + return string.Join("|", keys); } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) From 82a652e8f05c7b123469049a076c3d6819858a09 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:48:42 +0200 Subject: [PATCH 22/35] Stop filtering the Unity audio recovery on deviceWasChanged Device testing (Pixel 8a / Android 16): disconnecting a Bluetooth headset does raise AudioSettings.OnAudioConfigurationChanged, but with deviceWasChanged=false, so the flag cannot separate a device change from any other reconfiguration and the recovery never ran. The sample now reacts to the callback either way; the callback AudioSettings.Reset raises itself always lands inside the coalescing window, so the recovery still cannot feed itself. This also makes Unity's callback the fast path rather than the SDK's event: a powered-off headset can stay in the platform's device list for seconds after the route moved, so the device-set signal trails the disconnect. The reset now reports how many sources came back and on which output, so a run can tell a failed reopen from a successful one that stayed silent. Co-Authored-By: Claude Opus 5 (1M context) --- .../Runtime/Agent/PlatformAudioController.cs | 32 ++++++++++++------- .../Assets/Runtime/PlatformAudioController.cs | 32 ++++++++++++------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 263ac191..a7f13c91 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -267,24 +267,26 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList playout, IReadOnlyList Date: Tue, 25 Aug 2026 10:14:39 +0200 Subject: [PATCH 23/35] Restore game audio from remembered state, not from a live snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adb traces of a Bluetooth connect on a Pixel 8a (Android 16) show why the recovery left the app silent: Unity stops every AudioSource when it reinitializes its engine and raises OnAudioConfigurationChanged 25 ms afterwards, so the handler's snapshot of "what is playing" was always empty and it restored nothing. 34.920 AudioTrack stop(11092): called with 92104 frames delivered 34.945 Unity audio configuration changed (deviceWasChanged=False, ...) 34.947 Reopening Unity's audio output (...), resuming 0 source(s) The same trace shows the two events are ordered the other way round than assumed: a headset's call profile appears ~650 ms before its media profile takes over (32.912 SCO available, 33.576 setA2dpActiveDevice), so the SDK's DevicesChanged arrives while the engine is still healthy and Unity's callback arrives after the damage. They are now used accordingly — DevicesChanged remembers what is audible and touches nothing, Unity's callback puts it back — instead of both racing to reopen the engine, which is what made the outcome depend on timing. Co-Authored-By: Claude Opus 5 (1M context) --- .../Runtime/Agent/PlatformAudioController.cs | 136 +++++++++--------- .../Assets/Runtime/PlatformAudioController.cs | 136 +++++++++--------- 2 files changed, 138 insertions(+), 134 deletions(-) diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index a7f13c91..7195be53 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -28,9 +28,10 @@ // duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { - // Long enough to swallow the second signal for the same route change: the SDK's - // Android poll can trail Unity's own notification by up to ~1.5 s. - const float UnityAudioResetCoalesceSeconds = 2f; + // Long enough to ignore the configuration-changed callback that AudioSettings.Reset + // raises itself (~60 ms on device), short enough that a later stage of the same device + // switch still gets its own recovery. + const float UnityAudioResetEchoSeconds = 0.5f; readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -40,8 +41,9 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - string _outputDevices; float _lastUnityAudioReset = float.NegativeInfinity; + // What should be audible after an output device change, remembered from before it. + readonly Dictionary _audibleSources = new Dictionary(); public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -216,8 +218,6 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); - // Baseline for the recovery: only a change from here on is a device change. - _outputDevices = OutputDeviceSetKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -239,62 +239,69 @@ bool InitializePlatformAudio() // DevicesChanged (on the Unity main thread) whenever the available devices or the // active route change — headset plugged/unplugged, Bluetooth connected, the route // re-pinned after a device disappeared. An app would refresh its device picker here. - // This sample also uses it to bring Unity's own audio back onto the new route (see - // ResetUnityAudioOutput). + // This sample also uses it as the early warning for the Unity-audio recovery below. void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { Debug.Log("[PlatformAudioController] Audio devices changed.\n" + FormatDeviceLists(playout, recording)); - // Recover Unity audio when a device was added or removed — not when the route - // merely moved between the devices already connected. Device-verified on a - // Pixel 8a (Android 16): connecting or disconnecting a Bluetooth headset kills - // game audio, while the route changes a call brings with it (including the - // platform moving media from the headset's A2DP link onto its call link when the - // call starts) leave it playing. Resetting on those too would restart the game's - // audio at every join and hang-up for nothing. - var devices = OutputDeviceSetKey(playout); - if (devices == _outputDevices) - return; - _outputDevices = devices; - ResetUnityAudioOutput("output device added or removed"); + // Note what is audible while the engine is still healthy, but do not touch it: + // this event arrives early in a device switch (device-verified on a Pixel 8a / + // Android 16, where a Bluetooth headset's call profile appears ~650 ms before the + // media profile takes over), so reopening the engine here would reopen it onto the + // output the platform is about to leave. + RememberAudibleSources(); } - // Unity's audio engine opens an output device when the app starts and keeps writing - // to it: when that device goes away or a new one takes over (Bluetooth connect or - // disconnect, wired plug/unplug), game audio does not follow and does not recover on - // its own. Reopening the engine with AudioSettings.Reset is the fix, and it is - // deliberately the app's call rather than the SDK's — it stops every AudioSource in - // the scene. + // Unity's audio engine opens an output device when the app starts. When that device + // goes away or another one takes over (Bluetooth connect or disconnect, wired + // plug/unplug), Unity reinitializes the engine, which stops every AudioSource, and + // raises this callback afterwards — device-verified on a Pixel 8a (Android 16): + // + // AudioTrack stop(11092): called with 92104 frames delivered <- sources stopped + // [PlatformAudioController] Unity audio configuration changed <- 25 ms later // - // Two signals drive it, and both are needed — device-verified on a Pixel 8a - // (Android 16), where each one alone misses the case the other catches: - // - AudioSettings.OnAudioConfigurationChanged, Unity's own notification. It fires - // immediately when a Bluetooth headset disconnects, but with - // deviceWasChanged=false, so the flag cannot be used to tell a device change from - // any other reconfiguration — this sample reacts to the callback either way. - // - PlatformAudio.DevicesChanged, the SDK's routing event, as the backstop for - // platforms or transitions where Unity stays quiet. It can be the slower of the - // two on Bluetooth teardown: a powered-off headset can linger in the platform's - // device list for several seconds after the route has already moved. - // Whichever arrives first triggers the reset; ResetUnityAudioOutput coalesces the - // other one, along with the callback that AudioSettings.Reset raises itself (it - // always lands inside the coalescing window, so the recovery cannot feed itself). + // So the app has to restart its audio here, and it cannot learn what to restart from + // the scene at this point: everything is already stopped. What should be audible has + // to be remembered from before the switch (RememberAudibleSources) and put back now. + // Leaving that out is exactly how game audio ends up silent on the new device. + // + // deviceWasChanged is false even for a real device change on Android, so it cannot be + // used to filter these callbacks; the recovery reacts to all of them and relies on the + // echo window to ignore the callback its own reset raises. void OnUnityAudioConfigurationChanged(bool deviceWasChanged) { Debug.Log("[PlatformAudioController] Unity audio configuration changed " + $"(deviceWasChanged={deviceWasChanged}, outputSampleRate={AudioSettings.outputSampleRate}, " + $"speakerMode={AudioSettings.speakerMode})."); - ResetUnityAudioOutput("Unity audio configuration change"); + // Also refreshes the remembered set: anything still playing is the truth for the + // next switch, and finished one-shots drop out of it. + RememberAudibleSources(); + RestoreUnityAudioOutput("Unity audio configuration change"); + } + + // Records what this sample intends to keep audible, so a device change can put it + // back. Looping sources stay remembered while they are stopped, because that is what + // a reinitialized engine leaves behind; one-shots are forgotten once they finish. An + // app would consult its own audio state here instead of sweeping the scene. + void RememberAudibleSources() + { + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (source.isPlaying) + _audibleSources[source] = source.time; + else if (!source.loop) + _audibleSources.Remove(source); + } } - void ResetUnityAudioOutput(string reason) + void RestoreUnityAudioOutput(string reason) { // Both callers run on the Unity main thread (the SDK marshals DevicesChanged // there), so the Unity APIs below are safe to touch. var now = Time.realtimeSinceStartup; - if (now - _lastUnityAudioReset < UnityAudioResetCoalesceSeconds) + if (now - _lastUnityAudioReset < UnityAudioResetEchoSeconds) { Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " + $"ago, skipping ({reason})."); @@ -302,16 +309,15 @@ void ResetUnityAudioOutput(string reason) } _lastUnityAudioReset = now; - // Reset stops every AudioSource, so remember what was playing and where, and - // pick each one up at the same position on the reopened engine; an app would - // restart its own music/SFX here instead of sweeping the scene. - var playing = new List<(AudioSource Source, float Time)>(); - foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) - if (source.isPlaying) - playing.Add((source, source.time)); + // A looping source that was never seen playing is adopted here rather than left + // silent: it can only have been stopped by the engine reinitializing. + if (_audibleSources.Count == 0) + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + if (source.loop) + _audibleSources[source] = 0f; Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " - + $"resuming {playing.Count} source(s)."); + + $"restoring {_audibleSources.Count} source(s)."); if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) { @@ -319,34 +325,30 @@ void ResetUnityAudioOutput(string reason) return; } - var resumed = 0; - foreach (var (source, time) in playing) + var restored = 0; + // Copied because the loop drops destroyed sources from the dictionary. + foreach (var entry in new List>(_audibleSources)) { - if (source == null) continue; + var source = entry.Key; + if (source == null) + { + _audibleSources.Remove(source); + continue; + } source.Stop(); - source.time = time; + if (source.clip != null) + source.time = Mathf.Clamp(entry.Value, 0f, Mathf.Max(0f, source.clip.length - 0.05f)); source.Play(); - if (source.isPlaying) resumed++; + if (source.isPlaying) restored++; } // Reported so a device run can tell "the engine came back and the sources are // running" from "the sources are running but nothing is audible" — the second // would mean the reset did not reopen the output the platform actually moved to. - Debug.Log($"[PlatformAudioController] Unity audio output reopened, {resumed}/{playing.Count} " + Debug.Log($"[PlatformAudioController] Unity audio output reopened, {restored}/{_audibleSources.Count} " + $"source(s) playing on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); } - // Identifies the set of connected output devices, ignoring which one is active and - // the order the platform enumerated them in. - static string OutputDeviceSetKey(IReadOnlyList playout) - { - var keys = new List(playout.Count); - foreach (var device in playout) - keys.Add(string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid); - keys.Sort(StringComparer.Ordinal); - return string.Join("|", keys); - } - static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) { var sb = new StringBuilder("Playout devices:"); diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index a7f13c91..7195be53 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -28,9 +28,10 @@ // duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { - // Long enough to swallow the second signal for the same route change: the SDK's - // Android poll can trail Unity's own notification by up to ~1.5 s. - const float UnityAudioResetCoalesceSeconds = 2f; + // Long enough to ignore the configuration-changed callback that AudioSettings.Reset + // raises itself (~60 ms on device), short enough that a later stage of the same device + // switch still gets its own recovery. + const float UnityAudioResetEchoSeconds = 0.5f; readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -40,8 +41,9 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - string _outputDevices; float _lastUnityAudioReset = float.NegativeInfinity; + // What should be audible after an output device change, remembered from before it. + readonly Dictionary _audibleSources = new Dictionary(); public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -216,8 +218,6 @@ bool InitializePlatformAudio() var (recording, playout) = _platformAudio.GetDevices(); Debug.Log(FormatDeviceLists(playout, recording)); - // Baseline for the recovery: only a change from here on is a device change. - _outputDevices = OutputDeviceSetKey(playout); if (_platformAudio.RecordingDeviceCount > 0) _platformAudio.SetRecordingDevice(0); @@ -239,62 +239,69 @@ bool InitializePlatformAudio() // DevicesChanged (on the Unity main thread) whenever the available devices or the // active route change — headset plugged/unplugged, Bluetooth connected, the route // re-pinned after a device disappeared. An app would refresh its device picker here. - // This sample also uses it to bring Unity's own audio back onto the new route (see - // ResetUnityAudioOutput). + // This sample also uses it as the early warning for the Unity-audio recovery below. void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList recording) { Debug.Log("[PlatformAudioController] Audio devices changed.\n" + FormatDeviceLists(playout, recording)); - // Recover Unity audio when a device was added or removed — not when the route - // merely moved between the devices already connected. Device-verified on a - // Pixel 8a (Android 16): connecting or disconnecting a Bluetooth headset kills - // game audio, while the route changes a call brings with it (including the - // platform moving media from the headset's A2DP link onto its call link when the - // call starts) leave it playing. Resetting on those too would restart the game's - // audio at every join and hang-up for nothing. - var devices = OutputDeviceSetKey(playout); - if (devices == _outputDevices) - return; - _outputDevices = devices; - ResetUnityAudioOutput("output device added or removed"); + // Note what is audible while the engine is still healthy, but do not touch it: + // this event arrives early in a device switch (device-verified on a Pixel 8a / + // Android 16, where a Bluetooth headset's call profile appears ~650 ms before the + // media profile takes over), so reopening the engine here would reopen it onto the + // output the platform is about to leave. + RememberAudibleSources(); } - // Unity's audio engine opens an output device when the app starts and keeps writing - // to it: when that device goes away or a new one takes over (Bluetooth connect or - // disconnect, wired plug/unplug), game audio does not follow and does not recover on - // its own. Reopening the engine with AudioSettings.Reset is the fix, and it is - // deliberately the app's call rather than the SDK's — it stops every AudioSource in - // the scene. + // Unity's audio engine opens an output device when the app starts. When that device + // goes away or another one takes over (Bluetooth connect or disconnect, wired + // plug/unplug), Unity reinitializes the engine, which stops every AudioSource, and + // raises this callback afterwards — device-verified on a Pixel 8a (Android 16): + // + // AudioTrack stop(11092): called with 92104 frames delivered <- sources stopped + // [PlatformAudioController] Unity audio configuration changed <- 25 ms later // - // Two signals drive it, and both are needed — device-verified on a Pixel 8a - // (Android 16), where each one alone misses the case the other catches: - // - AudioSettings.OnAudioConfigurationChanged, Unity's own notification. It fires - // immediately when a Bluetooth headset disconnects, but with - // deviceWasChanged=false, so the flag cannot be used to tell a device change from - // any other reconfiguration — this sample reacts to the callback either way. - // - PlatformAudio.DevicesChanged, the SDK's routing event, as the backstop for - // platforms or transitions where Unity stays quiet. It can be the slower of the - // two on Bluetooth teardown: a powered-off headset can linger in the platform's - // device list for several seconds after the route has already moved. - // Whichever arrives first triggers the reset; ResetUnityAudioOutput coalesces the - // other one, along with the callback that AudioSettings.Reset raises itself (it - // always lands inside the coalescing window, so the recovery cannot feed itself). + // So the app has to restart its audio here, and it cannot learn what to restart from + // the scene at this point: everything is already stopped. What should be audible has + // to be remembered from before the switch (RememberAudibleSources) and put back now. + // Leaving that out is exactly how game audio ends up silent on the new device. + // + // deviceWasChanged is false even for a real device change on Android, so it cannot be + // used to filter these callbacks; the recovery reacts to all of them and relies on the + // echo window to ignore the callback its own reset raises. void OnUnityAudioConfigurationChanged(bool deviceWasChanged) { Debug.Log("[PlatformAudioController] Unity audio configuration changed " + $"(deviceWasChanged={deviceWasChanged}, outputSampleRate={AudioSettings.outputSampleRate}, " + $"speakerMode={AudioSettings.speakerMode})."); - ResetUnityAudioOutput("Unity audio configuration change"); + // Also refreshes the remembered set: anything still playing is the truth for the + // next switch, and finished one-shots drop out of it. + RememberAudibleSources(); + RestoreUnityAudioOutput("Unity audio configuration change"); + } + + // Records what this sample intends to keep audible, so a device change can put it + // back. Looping sources stay remembered while they are stopped, because that is what + // a reinitialized engine leaves behind; one-shots are forgotten once they finish. An + // app would consult its own audio state here instead of sweeping the scene. + void RememberAudibleSources() + { + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + if (source.isPlaying) + _audibleSources[source] = source.time; + else if (!source.loop) + _audibleSources.Remove(source); + } } - void ResetUnityAudioOutput(string reason) + void RestoreUnityAudioOutput(string reason) { // Both callers run on the Unity main thread (the SDK marshals DevicesChanged // there), so the Unity APIs below are safe to touch. var now = Time.realtimeSinceStartup; - if (now - _lastUnityAudioReset < UnityAudioResetCoalesceSeconds) + if (now - _lastUnityAudioReset < UnityAudioResetEchoSeconds) { Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " + $"ago, skipping ({reason})."); @@ -302,16 +309,15 @@ void ResetUnityAudioOutput(string reason) } _lastUnityAudioReset = now; - // Reset stops every AudioSource, so remember what was playing and where, and - // pick each one up at the same position on the reopened engine; an app would - // restart its own music/SFX here instead of sweeping the scene. - var playing = new List<(AudioSource Source, float Time)>(); - foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) - if (source.isPlaying) - playing.Add((source, source.time)); + // A looping source that was never seen playing is adopted here rather than left + // silent: it can only have been stopped by the engine reinitializing. + if (_audibleSources.Count == 0) + foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + if (source.loop) + _audibleSources[source] = 0f; Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " - + $"resuming {playing.Count} source(s)."); + + $"restoring {_audibleSources.Count} source(s)."); if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) { @@ -319,34 +325,30 @@ void ResetUnityAudioOutput(string reason) return; } - var resumed = 0; - foreach (var (source, time) in playing) + var restored = 0; + // Copied because the loop drops destroyed sources from the dictionary. + foreach (var entry in new List>(_audibleSources)) { - if (source == null) continue; + var source = entry.Key; + if (source == null) + { + _audibleSources.Remove(source); + continue; + } source.Stop(); - source.time = time; + if (source.clip != null) + source.time = Mathf.Clamp(entry.Value, 0f, Mathf.Max(0f, source.clip.length - 0.05f)); source.Play(); - if (source.isPlaying) resumed++; + if (source.isPlaying) restored++; } // Reported so a device run can tell "the engine came back and the sources are // running" from "the sources are running but nothing is audible" — the second // would mean the reset did not reopen the output the platform actually moved to. - Debug.Log($"[PlatformAudioController] Unity audio output reopened, {resumed}/{playing.Count} " + Debug.Log($"[PlatformAudioController] Unity audio output reopened, {restored}/{_audibleSources.Count} " + $"source(s) playing on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); } - // Identifies the set of connected output devices, ignoring which one is active and - // the order the platform enumerated them in. - static string OutputDeviceSetKey(IReadOnlyList playout) - { - var keys = new List(playout.Count); - foreach (var device in playout) - keys.Add(string.IsNullOrEmpty(device.Guid) ? device.Name : device.Guid); - keys.Sort(StringComparer.Ordinal); - return string.Join("|", keys); - } - static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) { var sb = new StringBuilder("Playout devices:"); From 54438d17f4631130f80226634aab20bf48a15eee Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:28:13 +0200 Subject: [PATCH 24/35] Stop resetting Unity's audio engine: it breaks the platform's call route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adb traces of a mid-call Bluetooth connect on a Pixel 8a (Android 16) show AudioSettings.Reset is not merely unnecessary but actively harmful. Reinitializing the engine makes Unity claim the headset's call link through the deprecated AudioManager.startBluetoothSco(), which evicts the setCommunicationDevice pin the routing backend holds and leaves the platform unable to bring SCO up again: 03.281 setCommunicationRouteForClient … bt_sco_hs addr:…E0:03 (setCommunicationDevice, ours) 03.928 [AudioSettings.Reset] 04.060 setCommunicationRouteForClient … null (stopBluetoothSco, Unity) 04.083 setCommunicationRouteForClient … bt_sco addr: (startBluetoothSco, Unity) 04.783 AS.BtHelper: requestScoState: failed to connect in state 1 06.295 … and on every 1.5 s re-pin thereafter Call audio and game audio both stayed on the loudspeaker for the rest of the session as a result. Unity has already reopened its output by the time it raises OnAudioConfigurationChanged, so the recovery only has to restart the app's own sources — which it now does, idempotently, leaving anything Unity kept running alone. The README's advice to reset is replaced with the reason not to. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 +- .../Runtime/Agent/PlatformAudioController.cs | 64 ++++++++----------- .../Assets/Runtime/PlatformAudioController.cs | 64 ++++++++----------- 3 files changed, 55 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index af65302c..8f541421 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,9 @@ 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. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call asserts the call session 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: it opens an output device when the app starts and does not follow a later change of that device on its own — connecting or disconnecting a headset leaves game audio writing into a device that is gone. An app that plays its own audio (music, SFX) alongside calls has to reopen the engine with `AudioSettings.Reset(AudioSettings.GetConfiguration())` when that happens — `DevicesChanged` is a signal for it; the Meet sample's `PlatformAudioController` shows the recovery. The SDK deliberately does not do it behind your back, because a reset stops every `AudioSource` in the scene. +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. + +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. diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 7195be53..4408bf99 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -28,11 +28,6 @@ // duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { - // Long enough to ignore the configuration-changed callback that AudioSettings.Reset - // raises itself (~60 ms on device), short enough that a later stage of the same device - // switch still gets its own recovery. - const float UnityAudioResetEchoSeconds = 0.5f; - readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -41,7 +36,6 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - float _lastUnityAudioReset = float.NegativeInfinity; // What should be audible after an output device change, remembered from before it. readonly Dictionary _audibleSources = new Dictionary(); @@ -278,7 +272,7 @@ void OnUnityAudioConfigurationChanged(bool deviceWasChanged) // Also refreshes the remembered set: anything still playing is the truth for the // next switch, and finished one-shots drop out of it. RememberAudibleSources(); - RestoreUnityAudioOutput("Unity audio configuration change"); + RestartAudibleSources(); } // Records what this sample intends to keep audible, so a device change can put it @@ -296,36 +290,30 @@ void RememberAudibleSources() } } - void RestoreUnityAudioOutput(string reason) + // Puts the remembered audio back on the reopened engine. Note what this does NOT do: + // it never calls AudioSettings.Reset. Unity has already reopened its output by the + // time it raises the callback, so a reset adds nothing — and on Android it does real + // harm. Device-verified on a Pixel 8a (Android 16): reinitializing the engine makes + // Unity claim the headset's call link through the deprecated + // AudioManager.startBluetoothSco(), which evicts the SDK's setCommunicationDevice pin + // and leaves the platform's SCO state machine unable to connect — + // + // AS.AudioDeviceBroker: setCommunicationRouteForClient … type:bt_sco addr: + // … from API: startBluetoothSco()) from u/pid:… <- evicts our pinned device + // AS.BtHelper: requestScoState: failed to connect in state 1 <- every retry after + // + // after which call audio and game audio are both stuck on the loudspeaker for the + // rest of the session, however often the SDK re-pins the route. + void RestartAudibleSources() { - // Both callers run on the Unity main thread (the SDK marshals DevicesChanged - // there), so the Unity APIs below are safe to touch. - var now = Time.realtimeSinceStartup; - if (now - _lastUnityAudioReset < UnityAudioResetEchoSeconds) - { - Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " - + $"ago, skipping ({reason})."); - return; - } - _lastUnityAudioReset = now; - - // A looping source that was never seen playing is adopted here rather than left - // silent: it can only have been stopped by the engine reinitializing. + // A looping source that was never seen playing is adopted rather than left silent: + // it can only have been stopped by the engine reinitializing. if (_audibleSources.Count == 0) foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.loop) _audibleSources[source] = 0f; - Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " - + $"restoring {_audibleSources.Count} source(s)."); - - if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) - { - Debug.LogWarning("[PlatformAudioController] AudioSettings.Reset failed; Unity audio may stay silent."); - return; - } - - var restored = 0; + var restarted = 0; // Copied because the loop drops destroyed sources from the dictionary. foreach (var entry in new List>(_audibleSources)) { @@ -335,18 +323,18 @@ void RestoreUnityAudioOutput(string reason) _audibleSources.Remove(source); continue; } - source.Stop(); + // Idempotent on purpose: a device switch raises several of these callbacks, + // and anything Unity left running must be left alone. + if (source.isPlaying) continue; + if (source.clip != null) source.time = Mathf.Clamp(entry.Value, 0f, Mathf.Max(0f, source.clip.length - 0.05f)); source.Play(); - if (source.isPlaying) restored++; + if (source.isPlaying) restarted++; } - // Reported so a device run can tell "the engine came back and the sources are - // running" from "the sources are running but nothing is audible" — the second - // would mean the reset did not reopen the output the platform actually moved to. - Debug.Log($"[PlatformAudioController] Unity audio output reopened, {restored}/{_audibleSources.Count} " - + $"source(s) playing on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); + Debug.Log($"[PlatformAudioController] Restarted {restarted} of {_audibleSources.Count} remembered " + + $"source(s) on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 7195be53..4408bf99 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -28,11 +28,6 @@ // duration of a call. See Initialize and SetSessionAudioEnabled. public sealed class PlatformAudioController : IDisposable { - // Long enough to ignore the configuration-changed callback that AudioSettings.Reset - // raises itself (~60 ms on device), short enough that a later stage of the same device - // switch still gets its own recovery. - const float UnityAudioResetEchoSeconds = 0.5f; - readonly string _trackName; readonly AudioProcessingOptions _audioOptions; @@ -41,7 +36,6 @@ public sealed class PlatformAudioController : IDisposable LocalAudioTrack _track; Room _room; bool _isRecording; - float _lastUnityAudioReset = float.NegativeInfinity; // What should be audible after an output device change, remembered from before it. readonly Dictionary _audibleSources = new Dictionary(); @@ -278,7 +272,7 @@ void OnUnityAudioConfigurationChanged(bool deviceWasChanged) // Also refreshes the remembered set: anything still playing is the truth for the // next switch, and finished one-shots drop out of it. RememberAudibleSources(); - RestoreUnityAudioOutput("Unity audio configuration change"); + RestartAudibleSources(); } // Records what this sample intends to keep audible, so a device change can put it @@ -296,36 +290,30 @@ void RememberAudibleSources() } } - void RestoreUnityAudioOutput(string reason) + // Puts the remembered audio back on the reopened engine. Note what this does NOT do: + // it never calls AudioSettings.Reset. Unity has already reopened its output by the + // time it raises the callback, so a reset adds nothing — and on Android it does real + // harm. Device-verified on a Pixel 8a (Android 16): reinitializing the engine makes + // Unity claim the headset's call link through the deprecated + // AudioManager.startBluetoothSco(), which evicts the SDK's setCommunicationDevice pin + // and leaves the platform's SCO state machine unable to connect — + // + // AS.AudioDeviceBroker: setCommunicationRouteForClient … type:bt_sco addr: + // … from API: startBluetoothSco()) from u/pid:… <- evicts our pinned device + // AS.BtHelper: requestScoState: failed to connect in state 1 <- every retry after + // + // after which call audio and game audio are both stuck on the loudspeaker for the + // rest of the session, however often the SDK re-pins the route. + void RestartAudibleSources() { - // Both callers run on the Unity main thread (the SDK marshals DevicesChanged - // there), so the Unity APIs below are safe to touch. - var now = Time.realtimeSinceStartup; - if (now - _lastUnityAudioReset < UnityAudioResetEchoSeconds) - { - Debug.Log($"[PlatformAudioController] Unity audio already reopened {now - _lastUnityAudioReset:0.00}s " - + $"ago, skipping ({reason})."); - return; - } - _lastUnityAudioReset = now; - - // A looping source that was never seen playing is adopted here rather than left - // silent: it can only have been stopped by the engine reinitializing. + // A looping source that was never seen playing is adopted rather than left silent: + // it can only have been stopped by the engine reinitializing. if (_audibleSources.Count == 0) foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.loop) _audibleSources[source] = 0f; - Debug.Log($"[PlatformAudioController] Reopening Unity's audio output ({reason}), " - + $"restoring {_audibleSources.Count} source(s)."); - - if (!AudioSettings.Reset(AudioSettings.GetConfiguration())) - { - Debug.LogWarning("[PlatformAudioController] AudioSettings.Reset failed; Unity audio may stay silent."); - return; - } - - var restored = 0; + var restarted = 0; // Copied because the loop drops destroyed sources from the dictionary. foreach (var entry in new List>(_audibleSources)) { @@ -335,18 +323,18 @@ void RestoreUnityAudioOutput(string reason) _audibleSources.Remove(source); continue; } - source.Stop(); + // Idempotent on purpose: a device switch raises several of these callbacks, + // and anything Unity left running must be left alone. + if (source.isPlaying) continue; + if (source.clip != null) source.time = Mathf.Clamp(entry.Value, 0f, Mathf.Max(0f, source.clip.length - 0.05f)); source.Play(); - if (source.isPlaying) restored++; + if (source.isPlaying) restarted++; } - // Reported so a device run can tell "the engine came back and the sources are - // running" from "the sources are running but nothing is audible" — the second - // would mean the reset did not reopen the output the platform actually moved to. - Debug.Log($"[PlatformAudioController] Unity audio output reopened, {restored}/{_audibleSources.Count} " - + $"source(s) playing on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); + Debug.Log($"[PlatformAudioController] Restarted {restarted} of {_audibleSources.Count} remembered " + + $"source(s) on {AudioSettings.speakerMode} @ {AudioSettings.outputSampleRate} Hz."); } static string FormatDeviceLists(IReadOnlyList playout, IReadOnlyList recording) From fe80315f7a6f3f822e5367553198e20269315b91 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:52:36 +0200 Subject: [PATCH 25/35] Let a Bluetooth route pin finish before re-issuing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a Bluetooth device is not synchronous: setCommunicationDevice starts an SCO negotiation and the platform keeps reporting the previous communication device until it completes. The 1.5 s poll took that for a dropped pin and re-issued the request into its own pending activation, which the platform refuses — and the refusal aborts the activation, so the route never arrived at all. Every failure in the trace lands on the poll's cadence, with nothing else in the process asking for SCO: 10:41:38.685 setCommunicationDevice() -> updateCommunicationRoute, preferredCommunicationDevice: null 10:41:40.193 … 41.705 … 43.216 … (every 1.5 s for the whole call) 10:41:50.764 AS.BtHelper: requestScoState: failed to connect in state 1 AS.AudioDeviceBroker: failure to start BT SCO for uid: 10424 Call audio and the app's own media both stayed on the loudspeaker for the duration, and both returned to the headset on hang-up, when the pin was cleared. An outstanding pin now gets PinSettleTimeout to take effect before being issued again. Recovering from a pin the platform drops silently — what the poll exists for since PAR-020 — is unaffected: once the pin has been seen honored, any later divergence re-pins immediately, and real route changes arrive through the change listener rather than the poll. This only became reachable with the session gating: while the route was pinned at construction, it had long since been applied before any call, so the poll never re-issued it. Co-Authored-By: Claude Opus 5 (1M context) --- .../Scripts/Audio/AndroidRouteController.cs | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 9da5023a..87e4cf7d 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -62,6 +62,15 @@ internal sealed class AndroidRouteController : IRouteController private const int MinSupportedApiLevel = 31; private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1.5); + // How long a pin is given to take effect before it is issued again. Selecting a + // Bluetooth device starts an asynchronous SCO negotiation, and until it completes + // the platform keeps reporting the previous communication device — so without this + // the poll re-issues the pin into its own pending activation, which the platform + // refuses ("BtHelper: requestScoState: failed to connect in state 1", device-verified + // on a Pixel 8a / Android 16) and the route never arrives at all. Real route changes + // come through the change listener, so this only slows down recovering from a pin the + // platform dropped silently. + private static readonly TimeSpan PinSettleTimeout = TimeSpan.FromSeconds(6); private readonly PlatformAudio _owner; private readonly object _gate = new object(); @@ -72,6 +81,10 @@ internal sealed class AndroidRouteController : IRouteController private List _ranked; private int _stickyDeviceId = -1; private int _pinnedDeviceId = -1; + // When the outstanding pin was issued, to give it PinSettleTimeout to take effect, + // and whether the platform has been seen honoring it since. + private DateTime _pinnedAtUtc = DateTime.MinValue; + private bool _pinApplied; // Session audio starts enabled, matching the documented default of // PlatformAudio.SetSessionAudioEnabled (uniform with iOS). private bool _sessionAudioEnabled = true; @@ -361,13 +374,35 @@ private void Reevaluate() else if (targetIndex >= 0) { var target = devices[targetIndex]; + if (currentId == _pinnedDeviceId) + _pinApplied = true; if (target.Id != currentId) { - var ok = audioManager.Call("setCommunicationDevice", target.Device); - Utils.Debug($"AndroidRouteController: setCommunicationDevice(kind={target.Kind}) -> {ok}"); - if (ok) - _pinnedDeviceId = target.Id; - selectedId = ok ? target.Id : currentId; + // A pin that has not taken effect yet is left to finish: + // re-issuing it lands in the platform's own pending SCO + // activation and gets refused, so hammering it keeps the + // route from ever arriving. Once the pin has been seen + // applied, a later divergence is the platform dropping it + // (what the poll exists for) and is re-pinned at once. + // See PinSettleTimeout. + var settling = _pinnedDeviceId == target.Id && !_pinApplied + && DateTime.UtcNow - _pinnedAtUtc < PinSettleTimeout; + if (settling) + { + selectedId = target.Id; + } + else + { + var ok = audioManager.Call("setCommunicationDevice", target.Device); + Utils.Debug($"AndroidRouteController: setCommunicationDevice(kind={target.Kind}) -> {ok}"); + if (ok) + { + _pinnedDeviceId = target.Id; + _pinnedAtUtc = DateTime.UtcNow; + _pinApplied = false; + } + selectedId = ok ? target.Id : currentId; + } } else { From c7317f5850c211553e614372a9d093a844bfbded Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:20:11 +0200 Subject: [PATCH 26/35] Back off and warn when the platform will not apply the route pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Withdrawing the outstanding legacy SCO request from here does not work. Measured on device: stopBluetoothSco ran 158 ms before the pin and the pin was refused all the same ("requestScoState: failed to connect in state 1"), because the request belongs to a different client in the process and cannot be cancelled by this one. So this is a situation the SDK can report but not repair. A pin the platform takes without acting on is now retried with a doubling backoff up to 30 s instead of every 6 s, and the first failure logs a warning naming the likely cause and the consequence, so it is diagnosable without LK_VERBOSE. The README documents it as a known limitation with the workaround that does work — connect the headset after the app has started. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 ++ .../Scripts/Audio/AndroidRouteController.cs | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8f541421..53876bf6 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,8 @@ While disabled, the SDK holds no call audio session: on iOS WebRTC's voice-proce 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 — a Bluetooth headset connected before the app starts.** When Unity's audio engine initializes with a Bluetooth headset already connected, it claims the headset's call link through the deprecated `AudioManager.startBluetoothSco()` — device-verified on a Pixel 8a (Android 16), roughly 3 s before this SDK creates its ADM, and not triggered by anything in the SDK or the samples. While that request is outstanding the platform refuses to bring the call link up for anyone (`AS.BtHelper: requestScoState: failed to connect in state 1`), so the SDK's route pin is accepted but never applied: call audio and the app's own media both play on the loudspeaker until the call ends, and the media returns to the headset when it does. The request cannot be withdrawn from here — it belongs to a different client in the process — so the SDK logs a warning and retries with backoff while it waits for the platform to clear it. Connecting the headset *after* the app has started avoids it entirely, and is currently the reliable workaround. + 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. diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 87e4cf7d..17b1831e 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -71,6 +71,9 @@ internal sealed class AndroidRouteController : IRouteController // come through the change listener, so this only slows down recovering from a pin the // platform dropped silently. private static readonly TimeSpan PinSettleTimeout = TimeSpan.FromSeconds(6); + // Ceiling for the backoff applied when the platform keeps taking the pin without + // acting on it — a state this SDK cannot clear (see the Bluetooth note in README). + private static readonly TimeSpan PinSettleTimeoutMax = TimeSpan.FromSeconds(30); private readonly PlatformAudio _owner; private readonly object _gate = new object(); @@ -85,6 +88,7 @@ internal sealed class AndroidRouteController : IRouteController // and whether the platform has been seen honoring it since. private DateTime _pinnedAtUtc = DateTime.MinValue; private bool _pinApplied; + private TimeSpan _pinSettleTimeout = PinSettleTimeout; // Session audio starts enabled, matching the documented default of // PlatformAudio.SetSessionAudioEnabled (uniform with iOS). private bool _sessionAudioEnabled = true; @@ -384,9 +388,10 @@ private void Reevaluate() // route from ever arriving. Once the pin has been seen // applied, a later divergence is the platform dropping it // (what the poll exists for) and is re-pinned at once. - // See PinSettleTimeout. - var settling = _pinnedDeviceId == target.Id && !_pinApplied - && DateTime.UtcNow - _pinnedAtUtc < PinSettleTimeout; + // See PinSettleTimeout, and _pinSettleTimeout for the backoff + // applied when the platform takes the pin but never acts on it. + var retry = _pinnedDeviceId == target.Id && !_pinApplied; + var settling = retry && DateTime.UtcNow - _pinnedAtUtc < _pinSettleTimeout; if (settling) { selectedId = target.Id; @@ -401,6 +406,27 @@ private void Reevaluate() _pinnedAtUtc = DateTime.UtcNow; _pinApplied = false; } + if (retry) + { + // The platform is taking the request and not acting on + // it. Back off rather than keep asking: retrying into + // an activation the platform will not start achieves + // nothing, and the cause is usually outside this SDK + // (see the Bluetooth note in the README). + Utils.Warning( + $"AndroidRouteController: the platform is not applying the route pin for " + + $"{target.Kind} after {_pinSettleTimeout.TotalSeconds:0}s. If this is a " + + "Bluetooth headset, another component in this process (Unity's audio engine " + + "does this when it initializes with a headset connected) may hold an " + + "outstanding startBluetoothSco request, which blocks the call link until it " + + "resolves. Call audio stays on the previous output until then."); + var next = TimeSpan.FromTicks(_pinSettleTimeout.Ticks * 2); + _pinSettleTimeout = next > PinSettleTimeoutMax ? PinSettleTimeoutMax : next; + } + else + { + _pinSettleTimeout = PinSettleTimeout; + } selectedId = ok ? target.Id : currentId; } } From c0b69161b0a2920145c8640d32e85a2f39cc3a9b Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:51:21 +0200 Subject: [PATCH 27/35] Describe the Bluetooth SCO limitation by what was measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trace containing both outcomes inside one call settled it. Joining with the headset connected since before launch left the pin unapplied; the headset then dropped, the platform ran resetBluetoothSco, and on reconnect the same pin was applied 708 ms later: 13:47:32.690 setCommunicationDevice(bt_sco_hs) -> preferredCommunicationDevice: null 13:47:40.227 updateCommunicationRoute … eventSource: resetBluetoothSco 13:47:49.828 setCommunicationDevice(bt_sco_hs) -> null 13:47:50.536 … preferredCommunicationDevice: bt_sco_hs eventSource: BtHelper.onScoAudioStateChanged, state: 12 So the limitation is a platform SCO state that can be left pending, not Unity's grab specifically — the grab provokes it but the previous wording made it the whole cause, and the same failure occurs on Unity 6, which does not grab. The note now says what the state does, what clears it (a headset reconnect, a Bluetooth toggle, a reboot), and that routing is prompt once it is clear. This also refutes the A2DP-streaming theory the card carried: the failing join happened with the app's own audio stopped. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53876bf6..4249fed3 100644 --- a/README.md +++ b/README.md @@ -461,7 +461,14 @@ While disabled, the SDK holds no call audio session: on iOS WebRTC's voice-proce 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 — a Bluetooth headset connected before the app starts.** When Unity's audio engine initializes with a Bluetooth headset already connected, it claims the headset's call link through the deprecated `AudioManager.startBluetoothSco()` — device-verified on a Pixel 8a (Android 16), roughly 3 s before this SDK creates its ADM, and not triggered by anything in the SDK or the samples. While that request is outstanding the platform refuses to bring the call link up for anyone (`AS.BtHelper: requestScoState: failed to connect in state 1`), so the SDK's route pin is accepted but never applied: call audio and the app's own media both play on the loudspeaker until the call ends, and the media returns to the headset when it does. The request cannot be withdrawn from here — it belongs to a different client in the process — so the SDK logs a warning and retries with backoff while it waits for the platform to clear it. Connecting the headset *after* the app has started avoids it entirely, and is currently the reliable workaround. +**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. From ccb67b94bcb1314ed2af6237c19a5b48cb9b3683 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:59:24 +0200 Subject: [PATCH 28/35] Close three session-lifetime holes found in the PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agents sample never re-enabled session audio. The shared controller's Initialize() hands the session back, and only MeetManager took it again on join — LiveKitAgentSession did not, so every agent call ran with session audio off: no VPIO unit on iOS (no mic, no agent audio at all), no communication mode and no route pin on Android 12+. The session is now enabled after the room connects and disabled in EndSession, and the controller comment no longer claims MeetManager is the only caller. A server-initiated disconnect left the microphone recording. OnDisconnected only disabled session audio; Unpublish/StopCapture ran solely from OnEndCall/OnDestroy. The capture is deliberately kept open across mute cycles, so after a kick, a deleted room, or a token expiry the mic stayed hot — indicator on — until the user pressed End Call. Both paths now share one idempotent TeardownCall. The pin settle/backoff state was only maintained on the success path. _pinnedAtUtc was stamped only when setCommunicationDevice returned true, so one refused re-issue froze the anchor; once the elapsed time passed the 30 s cap, "settling" was false on every pass and the backoff decayed into the warn+re-issue every 1.5 s poll tick it exists to prevent. The stamp now lands on every attempt, both release sites reset the tracking through one ResetPinTracking(), and the window is measured with the monotonic Stopwatch clock instead of DateTime.UtcNow, so a wall-clock step can no longer stretch or cut it. Compile-checked 8/8: package + Assembly-CSharp for Meet (editor, iOS player) and Agents (editor, Android player); sample controller copies byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- .../Scripts/Audio/AndroidRouteController.cs | 36 +++++++++++++++---- .../Runtime/Agent/LiveKitAgentSession.cs | 8 +++++ .../Runtime/Agent/PlatformAudioController.cs | 11 +++--- Samples~/Meet/Assets/Runtime/MeetManager.cs | 20 +++++++++-- .../Assets/Runtime/PlatformAudioController.cs | 11 +++--- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 17b1831e..1cf30e0d 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -84,9 +84,11 @@ internal sealed class AndroidRouteController : IRouteController private List _ranked; private int _stickyDeviceId = -1; private int _pinnedDeviceId = -1; - // When the outstanding pin was issued, to give it PinSettleTimeout to take effect, - // and whether the platform has been seen honoring it since. - private DateTime _pinnedAtUtc = DateTime.MinValue; + // When the outstanding pin was last issued — Stopwatch ticks, monotonic, so a + // wall-clock step can neither cut the settle window short nor stretch it — to + // give it _pinSettleTimeout to take effect, and whether the platform has been + // seen honoring it since. + private long _pinIssuedAtTimestamp; private bool _pinApplied; private TimeSpan _pinSettleTimeout = PinSettleTimeout; // Session audio starts enabled, matching the documented default of @@ -391,7 +393,7 @@ private void Reevaluate() // See PinSettleTimeout, and _pinSettleTimeout for the backoff // applied when the platform takes the pin but never acts on it. var retry = _pinnedDeviceId == target.Id && !_pinApplied; - var settling = retry && DateTime.UtcNow - _pinnedAtUtc < _pinSettleTimeout; + var settling = retry && ElapsedSincePinIssued() < _pinSettleTimeout; if (settling) { selectedId = target.Id; @@ -400,10 +402,14 @@ private void Reevaluate() { var ok = audioManager.Call("setCommunicationDevice", target.Device); Utils.Debug($"AndroidRouteController: setCommunicationDevice(kind={target.Kind}) -> {ok}"); + // Stamped on every attempt, not only on success: + // measured from a stale issue time the settle window + // expires for good after one refused re-issue, and the + // backoff decays into a warn+re-issue every poll tick. + _pinIssuedAtTimestamp = System.Diagnostics.Stopwatch.GetTimestamp(); if (ok) { _pinnedDeviceId = target.Id; - _pinnedAtUtc = DateTime.UtcNow; _pinApplied = false; } if (retry) @@ -440,7 +446,7 @@ private void Reevaluate() if (_pinnedDeviceId != -1) { audioManager.Call("clearCommunicationDevice"); - _pinnedDeviceId = -1; + ResetPinTracking(); Utils.Debug("AndroidRouteController: no ranked device available; cleared pin, OS default applies"); using var fallback = audioManager.Call("getCommunicationDevice"); selectedId = fallback != null ? fallback.Call("getId") : -1; @@ -480,6 +486,22 @@ private void Reevaluate() DevicesChanged?.Invoke(playout, new List(_recordingSnapshot)); } + // Called under _gate from both pin-release sites. Clears everything the + // settle/backoff logic keys on; anything left behind resurfaces on the next + // pin as a spurious settle-skip or backoff warning. + private void ResetPinTracking() + { + _pinnedDeviceId = -1; + _pinApplied = false; + _pinSettleTimeout = PinSettleTimeout; + } + + private TimeSpan ElapsedSincePinIssued() + { + var elapsedTicks = System.Diagnostics.Stopwatch.GetTimestamp() - _pinIssuedAtTimestamp; + return TimeSpan.FromSeconds((double)elapsedTicks / System.Diagnostics.Stopwatch.Frequency); + } + private bool SignatureChanged(List<(int Id, AudioOutputKind Kind, bool IsSelected)> signature) { if (_lastSignature == null || _lastSignature.Count != signature.Count) @@ -541,7 +563,7 @@ private void LeaveCommunicationMode() { using var audioManager = GetAudioManager(); audioManager.Call("clearCommunicationDevice"); - _pinnedDeviceId = -1; + ResetPinTracking(); if (_audioModeSaved) { audioManager.Call("setMode", _savedAudioMode); diff --git a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs index a2337bc1..a9d02612 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/LiveKitAgentSession.cs @@ -52,6 +52,8 @@ public void EndSession() _transcription?.Dispose(); _transcription = null; + // Hand the call audio session back before tearing the ADM down (see Connect). + _audio?.SetSessionAudioEnabled(false); _audio?.Dispose(); _audio = null; @@ -101,6 +103,12 @@ IEnumerator Connect() OnRoomConnected(_room); + // Session audio was handed back in Initialize(); take it now that the call is + // starting. On iOS this turns on WebRTC's VPIO unit (without it the agent call + // has no microphone and no audio at all), on Android 12+ it takes communication + // mode plus the SDK's route pin. EndSession hands it back. + _audio.SetSessionAudioEnabled(true); + yield return _audio.Publish(_room); if (!_audio.IsPublished) { diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 4408bf99..d52980dc 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -64,11 +64,12 @@ public bool Initialize() AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: this controller is created at app start (to keep one ADM alive for every - // call), while the platform's call audio session should only be held while a call - // is actually in progress — enabled means "in a call". Without this the app keeps - // requesting communication mode and pinning the call route from launch to quit. - // MeetManager re-enables it on join and disables it again on leave. + // back: the platform's call audio session should only be held while a call is + // actually in progress — enabled means "in a call". Without this an app that + // keeps one ADM alive across calls would request communication mode and pin the + // call route from launch to quit. The caller must re-enable it when its call + // starts and disable it again when the call ends (MeetManager does so on + // join/leave, LiveKitAgentSession around Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; } diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index e3fd7b23..7c526705 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -162,10 +162,19 @@ private void OnEndCall() _platformAudioController?.SetSessionAudioEnabled(false); _room.Disconnect(); + TeardownCall(); + } + + // Shared end-of-call teardown: stops the mic capture and the tracks + // (CleanUpAllTracks) and resets the call state and UI. Every part is safe to run + // twice — OnEndCall's own Disconnect can also raise OnDisconnected. + private void TeardownCall() + { CleanUpAllTracks(); _room = null; _localId = null; - buttonBar.SetConnected(false); + if (buttonBar != null) + buttonBar.SetConnected(false); } private void OnToggleCamera() @@ -445,10 +454,15 @@ private void OnDisconnected(Room room) { Debug.Log($"Disconnected from room: {room.DisconnectReason}"); - // Covers server-initiated disconnects as well as OnEndCall; idempotent with - // the call already made there. Keeps the audio session active for Unity. + // Covers server-initiated disconnects (kick, room deleted, token expiry) as + // well as OnEndCall. Stopping the capture here matters: it is deliberately + // kept running across mute cycles, so without the teardown a server-side + // disconnect would leave the microphone recording — indicator on — with no + // call to feed. The audio session itself stays active for Unity. if (usePlatformAudio) _platformAudioController?.SetSessionAudioEnabled(false); + + TeardownCall(); } private void OnTrackMuted(TrackPublication publication, Participant participant) diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 4408bf99..d52980dc 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -64,11 +64,12 @@ public bool Initialize() AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: this controller is created at app start (to keep one ADM alive for every - // call), while the platform's call audio session should only be held while a call - // is actually in progress — enabled means "in a call". Without this the app keeps - // requesting communication mode and pinning the call route from launch to quit. - // MeetManager re-enables it on join and disables it again on leave. + // back: the platform's call audio session should only be held while a call is + // actually in progress — enabled means "in a call". Without this an app that + // keeps one ADM alive across calls would request communication mode and pin the + // call route from launch to quit. The caller must re-enable it when its call + // starts and disable it again when the call ends (MeetManager does so on + // join/leave, LiveKitAgentSession around Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; } From 3afe3b5bea2c785fdfd9cafbbd46a1777d97fe02 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:00:42 +0200 Subject: [PATCH 29/35] Correct four doc claims the PR review caught out of sync with the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetSessionAudioEnabled (Android backend) promised the sticky override survives a disable/enable transition unconditionally. It does not: the drop-on-disappear bookkeeping keeps running while the session is disabled, so a device that leaves the list between calls clears the override for good. The doc now says so instead of promising otherwise. - SelectOutput never stated its session-enabled precondition on Android: while session audio is disabled the choice is only recorded, no pin is issued, and the device lists keep reporting the platform's route. Added to the XML doc and the README, which endorses pre-call device pickers. - StartRecording claimed recording starts automatically when PlatformAudio is created. The Rust side only acquires the ADM in PlatformAudio::new(); capture starts solely through start_recording. - The sample recovery comment told readers an "echo window" swallows the callback its own reset raises — both were removed on this branch; the real re-entry guard is idempotence (a playing source is left alone). Compile-checked 8/8; sample controller copies byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- Runtime/Scripts/Audio/AndroidRouteController.cs | 9 ++++++--- Runtime/Scripts/Audio/PlatformAudio.cs | 10 +++++++--- .../Assets/Runtime/Agent/PlatformAudioController.cs | 4 ++-- .../Meet/Assets/Runtime/PlatformAudioController.cs | 4 ++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4249fed3..cab944f4 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,7 @@ One consequence to design around on Android: while a call session is active on a 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. `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 SDK enters `MODE_IN_COMMUNICATION` and pins the route on enable, 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. +- **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. `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 SDK enters `MODE_IN_COMMUNICATION` and pins the route on enable, 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). diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 1cf30e0d..cd22fa88 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -218,9 +218,12 @@ public void ClearOutputOverride() /// /// Takes or hands back the call audio session: enabling enters /// MODE_IN_COMMUNICATION and lets the policy pin the route, disabling - /// clears the pin and restores the mode this controller replaced. The sticky - /// override and the ranked preference survive the transition, so a call that - /// re-enables the session routes exactly as it did before. + /// clears the pin and restores the mode this controller replaced. The ranked + /// preference survives the transition unconditionally. The sticky override + /// survives it only while its device stays available: the drop-on-disappear + /// bookkeeping keeps running while the session is disabled, so a device that + /// leaves the list between calls (a headset powered off) clears the override + /// for good, and the next call routes by the ranked preference. /// public void SetSessionAudioEnabled(bool enabled) { diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 7f0c0397..d5a7f736 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -464,7 +464,11 @@ public bool IsSpeakerOutputPreferred /// Platform notes: on desktop this selects the device like /// . On Android 12 (API 31) and newer the /// device is pinned as the communication device; the override is dropped once the - /// device disappears from the playout list (automatic policy resumes). On iOS the + /// device disappears from the playout list (automatic policy resumes). While + /// session audio is disabled () the choice is + /// only recorded — no pin is issued, and / + /// keep reporting the platform's own route — until a + /// call enables the session. On iOS the /// OS owns output route selection and this method throws /// — present the system route picker /// (AVRoutePickerView) instead, or use / @@ -647,8 +651,8 @@ public void SetPlayoutDevice(string deviceId) /// /// Starts recording from the microphone. /// - /// Recording is started automatically when PlatformAudio is created. - /// Use this to resume recording after calling StopRecording. + /// Recording does not start on its own when PlatformAudio is created — call + /// this to start capturing, and again to resume after . /// This turns on the system's recording privacy indicator (e.g., on macOS/iOS). /// On iOS this also switches the audio session to its recording state /// (voice/video-chat mode per , enabling diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index d52980dc..de1a9420 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -262,8 +262,8 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList playout, IReadOnlyList Date: Tue, 25 Aug 2026 17:04:23 +0200 Subject: [PATCH 30/35] Scope the settle gate to Bluetooth, report the route the platform has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two route-reporting/recovery changes in AndroidRouteController: - The pin settle window now applies only to Bluetooth targets. Its whole justification is the asynchronous SCO negotiation; the other kinds apply without one, so a divergence there is always a dropped or ignored pin and is re-issued immediately — restoring the pre-gate recovery speed for speaker/wired/USB. A pending Bluetooth pin is deliberately still not re-issued by the StartRecording re-assert: re-issuing lands in our own pending activation and aborts it (the F11 livelock), so the up-to-6 s wait in the join flow is the safer side of that trade. - Reevaluate now reports the device the platform actually has, never the one merely requested: the selected id is re-read after each issue and left at the current device while a pin settles. DevicesChanged and GetDevices() agree again, a stuck pin no longer shows the target as selected for the whole call, and the real arrival raises its own DevicesChanged (the optimistic report used to swallow it, since the signature never changed). And the sample recovery (both controller copies, byte-identical) fixes the holes the review verified: - Restore runs BEFORE remember in OnUnityAudioConfigurationChanged. The engine has already stopped every source at that point, so remembering first purged every interrupted one-shot and the restore put back nothing but loops. - The healthy-signal remember pass (OnDevicesChanged) now forgets any stopped source, loops included: it runs before the engine reinit, so a stopped source there was stopped by the app, and a deliberate Stop() must not be undone by the next device change. The post-restore pass only refreshes positions, so a Play() the engine rejected mid-burst keeps its slot for the next callback. - The adopt-all-loops fallback is gated to the very first switch (before any sweep has run); afterwards it would force-play loops the app stopped or never started. - Sources on deactivated GameObjects are dropped instead of being retried with a warning on every callback and force-played on reactivation. Compile-checked 8/8; sample controller copies byte-identical. Needs one device pass: BT join flow (settle + arrival event) and the Unity-audio recovery scenarios. Co-Authored-By: Claude Opus 5 (1M context) --- .../Scripts/Audio/AndroidRouteController.cs | 43 ++++++++++++----- .../Runtime/Agent/PlatformAudioController.cs | 48 ++++++++++++++----- .../Assets/Runtime/PlatformAudioController.cs | 48 ++++++++++++++----- 3 files changed, 104 insertions(+), 35 deletions(-) diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index cd22fa88..d6cfa349 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -387,19 +387,30 @@ private void Reevaluate() _pinApplied = true; if (target.Id != currentId) { - // A pin that has not taken effect yet is left to finish: - // re-issuing it lands in the platform's own pending SCO - // activation and gets refused, so hammering it keeps the - // route from ever arriving. Once the pin has been seen - // applied, a later divergence is the platform dropping it - // (what the poll exists for) and is re-pinned at once. - // See PinSettleTimeout, and _pinSettleTimeout for the backoff - // applied when the platform takes the pin but never acts on it. - var retry = _pinnedDeviceId == target.Id && !_pinApplied; + // A Bluetooth pin that has not taken effect yet is left to + // finish: setCommunicationDevice starts an asynchronous SCO + // negotiation there, and re-issuing lands in the platform's + // own pending activation and gets refused, so hammering it + // keeps the route from ever arriving. Once the pin has been + // seen applied, a later divergence is the platform dropping + // it (what the poll exists for) and is re-pinned at once. + // The other kinds apply without a negotiation, so a + // divergence there is always a dropped or ignored pin and + // is re-issued immediately, as before the settle window + // existed. See PinSettleTimeout, and _pinSettleTimeout for + // the backoff applied when the platform takes a Bluetooth + // pin but never acts on it. + var retry = _pinnedDeviceId == target.Id && !_pinApplied + && target.Kind == AudioOutputKind.Bluetooth; var settling = retry && ElapsedSincePinIssued() < _pinSettleTimeout; if (settling) { - selectedId = target.Id; + // Waiting on the negotiation: report the device the + // platform still has, never the one merely requested. + // The change listener re-runs this pass the moment the + // pin lands, and the selection flip raises the + // DevicesChanged for the real arrival. + selectedId = currentId; } else { @@ -436,7 +447,17 @@ private void Reevaluate() { _pinSettleTimeout = PinSettleTimeout; } - selectedId = ok ? target.Id : currentId; + // Report the platform's answer, not the request: the + // synchronous kinds are visible in this re-read right + // away, while a pending Bluetooth pin must not be + // announced as selected before it lands — GetDevices() + // reads the same truth, and a premature "selected" + // would also swallow the arrival event, because the + // signature would never change again. + using var applied = audioManager.Call("getCommunicationDevice"); + selectedId = applied != null ? applied.Call("getId") : -1; + if (ok && selectedId == target.Id) + _pinApplied = true; } } else diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index de1a9420..3566deac 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -38,6 +38,9 @@ public sealed class PlatformAudioController : IDisposable bool _isRecording; // What should be audible after an output device change, remembered from before it. readonly Dictionary _audibleSources = new Dictionary(); + // Whether a remember pass has ever swept the scene; gates the adopt-loops fallback + // in RestartAudibleSources to the very first switch. + bool _sceneSwept; public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -245,7 +248,7 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList(FindObjectsSortMode.None)) { if (source.isPlaying) _audibleSources[source] = source.time; - else if (!source.loop) + else if (forgetStopped) _audibleSources.Remove(source); } + _sceneSwept = true; } // Puts the remembered audio back on the reopened engine. Note what this does NOT do: @@ -307,9 +319,12 @@ void RememberAudibleSources() // rest of the session, however often the SDK re-pins the route. void RestartAudibleSources() { - // A looping source that was never seen playing is adopted rather than left silent: - // it can only have been stopped by the engine reinitializing. - if (_audibleSources.Count == 0) + // First-switch safety net: when no remember pass has ever swept the scene, a + // stopped looping source is taken to have been stopped by the engine reinit and + // is adopted rather than left silent. Once a sweep has run, an absent loop is + // one the app stopped (or never started), and adopting it would undo the app's + // intent. + if (_audibleSources.Count == 0 && !_sceneSwept) foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.loop) _audibleSources[source] = 0f; @@ -324,6 +339,15 @@ void RestartAudibleSources() _audibleSources.Remove(source); continue; } + // A source the app deactivated is treated like one it stopped: forgotten, + // not retried. Play() on a disabled source only logs a warning on every + // callback, and force-playing it after a reactivation would undo the app's + // intent. + if (!source.isActiveAndEnabled) + { + _audibleSources.Remove(source); + continue; + } // Idempotent on purpose: a device switch raises several of these callbacks, // and anything Unity left running must be left alone. if (source.isPlaying) continue; diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index de1a9420..3566deac 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -38,6 +38,9 @@ public sealed class PlatformAudioController : IDisposable bool _isRecording; // What should be audible after an output device change, remembered from before it. readonly Dictionary _audibleSources = new Dictionary(); + // Whether a remember pass has ever swept the scene; gates the adopt-loops fallback + // in RestartAudibleSources to the very first switch. + bool _sceneSwept; public bool IsInitialized => _platformAudio != null; public bool IsPublished { get; private set; } @@ -245,7 +248,7 @@ void OnDevicesChanged(IReadOnlyList playout, IReadOnlyList(FindObjectsSortMode.None)) { if (source.isPlaying) _audibleSources[source] = source.time; - else if (!source.loop) + else if (forgetStopped) _audibleSources.Remove(source); } + _sceneSwept = true; } // Puts the remembered audio back on the reopened engine. Note what this does NOT do: @@ -307,9 +319,12 @@ void RememberAudibleSources() // rest of the session, however often the SDK re-pins the route. void RestartAudibleSources() { - // A looping source that was never seen playing is adopted rather than left silent: - // it can only have been stopped by the engine reinitializing. - if (_audibleSources.Count == 0) + // First-switch safety net: when no remember pass has ever swept the scene, a + // stopped looping source is taken to have been stopped by the engine reinit and + // is adopted rather than left silent. Once a sweep has run, an absent loop is + // one the app stopped (or never started), and adopting it would undo the app's + // intent. + if (_audibleSources.Count == 0 && !_sceneSwept) foreach (var source in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) if (source.loop) _audibleSources[source] = 0f; @@ -324,6 +339,15 @@ void RestartAudibleSources() _audibleSources.Remove(source); continue; } + // A source the app deactivated is treated like one it stopped: forgotten, + // not retried. Play() on a disabled source only logs a warning on every + // callback, and force-playing it after a reactivation would undo the app's + // intent. + if (!source.isActiveAndEnabled) + { + _audibleSources.Remove(source); + continue; + } // Idempotent on purpose: a device switch raises several of these callbacks, // and anything Unity left running must be left alone. if (source.isPlaying) continue; From 2d4e685b73fd929afd7356cabc86a33e096b593e Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:37:08 +0200 Subject: [PATCH 31/35] Await the iOS microphone permission before opening the capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartRecording gated on the permission dialog on Android but not on iOS, so an app that starts its capture automatically — the Agents sample does, right after connecting — opened the ADM's input unit while the first-run permission prompt was still pending. A capture opened before the grant records silence, and nothing reopens the input after the user taps Allow, so the whole session published a silent microphone track: the agent never heard the user. Meet never shows this because the capture starts from the unmute button, on installs whose permission was granted long ago. The coroutine now mirrors the Android gate: request the authorization, yield until the user answers, throw on denial, and only then open the capture. Already-granted installs pass straight through. Inferred from the code and platform behavior; needs the device pass on a fresh install (prompt path) plus a second run (granted path). Co-Authored-By: Claude Opus 5 (1M context) --- Runtime/Scripts/Audio/PlatformAudio.cs | 19 +++++++++++++++++++ .../Runtime/Agent/PlatformAudioController.cs | 6 +++--- .../Assets/Runtime/PlatformAudioController.cs | 6 +++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index d5a7f736..81e65f42 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -653,6 +653,9 @@ public void SetPlayoutDevice(string deviceId) /// /// Recording does not start on its own when PlatformAudio is created — call /// this to start capturing, and again to resume after . + /// On Android and iOS the coroutine first awaits the OS microphone-permission + /// dialog when the permission has not been granted yet, and only then opens the + /// capture — a capture opened while the prompt is pending would record silence. /// This turns on the system's recording privacy indicator (e.g., on macOS/iOS). /// On iOS this also switches the audio session to its recording state /// (voice/video-chat mode per , enabling @@ -687,6 +690,22 @@ public IEnumerator StartRecording() } #endif +#if UNITY_IOS && !UNITY_EDITOR + if (!UnityEngine.Application.HasUserAuthorization(UnityEngine.UserAuthorization.Microphone)) + { + // Ask for the record permission BEFORE the ADM opens the input unit. The + // system prompt is asynchronous: a capture opened while it is still + // pending records silence, and nothing reopens the input after the user + // grants — so without this gate the first run of an app publishes a + // silent microphone track. + yield return UnityEngine.Application.RequestUserAuthorization( + UnityEngine.UserAuthorization.Microphone); + if (!UnityEngine.Application.HasUserAuthorization(UnityEngine.UserAuthorization.Microphone)) + throw new InvalidOperationException( + "Microphone permission denied by user; cannot start recording."); + } +#endif + using var request = FFIBridge.Instance.NewRequest(); request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle(); diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index 3566deac..f527036e 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -121,9 +121,9 @@ public IEnumerator Publish(Room room) } // Starts the microphone capture without publishing a track; Publish() reuses the - // running capture. On macOS/iOS this turns on the recording privacy indicator and - // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime - // permission dialog. On Android call this as soon as the call starts, even when + // running capture. On macOS this turns on the recording privacy indicator; on iOS + // and Android the coroutine first awaits the OS microphone-permission dialog and + // only then opens the capture. On Android call this as soon as the call starts, even when // joining muted: since Android 13 the app's communication-mode request — and with // it the SDK's output route pin — is only honored while the app has ACTIVE // voice-communication capture or playback, and the ADM's playout stream does not diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index 3566deac..f527036e 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -121,9 +121,9 @@ public IEnumerator Publish(Room room) } // Starts the microphone capture without publishing a track; Publish() reuses the - // running capture. On macOS/iOS this turns on the recording privacy indicator and - // triggers the OS permission prompt; on Android it awaits the RECORD_AUDIO runtime - // permission dialog. On Android call this as soon as the call starts, even when + // running capture. On macOS this turns on the recording privacy indicator; on iOS + // and Android the coroutine first awaits the OS microphone-permission dialog and + // only then opens the capture. On Android call this as soon as the call starts, even when // joining muted: since Android 13 the app's communication-mode request — and with // it the SDK's output route pin — is only honored while the app has ACTIVE // voice-communication capture or playback, and the ADM's playout stream does not From a1907b5fd1cb9840cb8aca124914894e4f3a9fdd Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:47:15 +0200 Subject: [PATCH 32/35] Retry a failed session enter/leave instead of giving up on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LeaveCommunicationMode swallows exceptions, and its caller flips _sessionAudioEnabled before it runs — so one transient JNI failure during a hang-up (e.g. the activity briefly unavailable during pause) left the platform in MODE_IN_COMMUNICATION with the route still pinned, while the controller reported the session released. A repeat disable early-returns on the unchanged flag, and the disabled-mode Reevaluate never issues a clear, so nothing repaired it until the next call or Dispose. The enable side had the same hole in the opposite direction. A failed transition now marks itself pending, and Reevaluate — where every trigger already funnels: the poll, the change listener, the StartRecording re-assert — retries the transition for the current desired state, so a flip that happened in between is never undone. Both methods are idempotent when re-run partially completed (mode save/restore already paired per transition; clearing an unheld pin is a no-op). The failure warns once; retries are silent and the next success clears the flag. Compile-checked 8/8. No dedicated device scenario: the path only differs when the platform throws mid-transition; a normal join/leave pass covers the regression risk. Co-Authored-By: Claude Opus 5 (1M context) --- .../Scripts/Audio/AndroidRouteController.cs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index d6cfa349..35b75fab 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -96,6 +96,11 @@ internal sealed class AndroidRouteController : IRouteController private bool _sessionAudioEnabled = true; private int _savedAudioMode; private bool _audioModeSaved; + // Set when an enter/leave transition failed (JNI unavailable, platform error) so + // Reevaluate retries it: without the retry, one transient failure on disable + // would leave the platform in MODE_IN_COMMUNICATION with the route pinned until + // the next call boundary, while this controller reports the session released. + private bool _sessionTransitionPending; private CommunicationDeviceListener _listener; private AndroidJavaObject _audioFocusRequest; private bool _audioFocusEnabled; @@ -327,6 +332,19 @@ private void Reevaluate() { if (_disposed) return; + // A failed enter/leave transition is retried from here: every trigger — + // the poll, the change listener, the StartRecording re-assert — funnels + // through this pass, so a transient JNI failure cannot leave the + // platform holding (or missing) the call session until the next call + // boundary. Retries the transition for the CURRENT desired state, so a + // flip that happened in between is never undone. + if (_sessionTransitionPending) + { + if (_sessionAudioEnabled) + EnterCommunicationMode(); + else + LeaveCommunicationMode(); + } try { using var audioManager = GetAudioManager(); @@ -561,7 +579,9 @@ private void PollLoop() // mode is only captured when we do not already hold one, and it is only restored // when it was actually read from the platform — a failed read must never turn // into an unconditional MODE_NORMAL, which would stomp a mode this app does not - // own (the rule the PAR-000 hotfix established). + // own (the rule the PAR-000 hotfix established). A failure marks the transition + // pending, and Reevaluate retries it (warned once, retries silent) — both + // methods are safe to re-run partially completed. private void EnterCommunicationMode() { try @@ -573,11 +593,14 @@ private void EnterCommunicationMode() _audioModeSaved = true; } audioManager.Call("setMode", ModeInCommunication); + _sessionTransitionPending = false; Utils.Debug($"AndroidRouteController: audio mode -> MODE_IN_COMMUNICATION (was {_savedAudioMode})"); } catch (Exception e) { - Utils.Warning($"AndroidRouteController: failed to enter communication mode: {e.Message}"); + if (!_sessionTransitionPending) + Utils.Warning($"AndroidRouteController: failed to enter communication mode (will retry): {e.Message}"); + _sessionTransitionPending = true; } } @@ -598,10 +621,13 @@ private void LeaveCommunicationMode() { Utils.Debug("AndroidRouteController: route cleared, no saved audio mode to restore"); } + _sessionTransitionPending = false; } catch (Exception e) { - Utils.Warning($"AndroidRouteController: failed to release the audio session: {e.Message}"); + if (!_sessionTransitionPending) + Utils.Warning($"AndroidRouteController: failed to release the audio session (will retry): {e.Message}"); + _sessionTransitionPending = true; } } From 5eecb19a872e5af98e6528cd64a90b0b6cff95dc Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:41:47 +0200 Subject: [PATCH 33/35] Acquire the Android call session lazily instead of in the constructor AndroidRouteController's constructor entered MODE_IN_COMMUNICATION and pinned the route because session audio defaults to enabled, and the recommended pattern of disabling right after construction then produced a take -> pin -> clear flap at every app launch (a measured 320 ms transient; with a Bluetooth headset connected, an SCO activation started only to be cleared mid-negotiation, inside the same startup window where the platform's SCO state machine is fragile). The enabled default and the PAR-019 API surface are unchanged. The constructor now only records the state; the session is first acquired by the first trigger that needs it while enabled: an explicit SetSessionAudioEnabled(true) call (also when the state was already enabled - the way in for receive-only apps), ApplyOutputPreference (which the StartRecording re-assert funnels through, where Android 13 starts honoring the mode request anyway) or SelectOutput. Disabling or disposing before anything acquired the session releases nothing, so the create -> disable startup path issues no audio-mode traffic at all. The mode save now happens at the actual acquisition and the save/restore idempotency rules are untouched; enumeration, the change listener and the poll thread still start in the constructor, and neither the poll nor the listener can take a lazily-deferred session. Behavior change, deliberate: an app that plays remote audio, never records, never touches routing and never calls SetSessionAudioEnabled no longer gets the mode and pin from construction; it opts in with SetSessionAudioEnabled(true) at its call boundary, per the documented "enabled == a call is in progress" contract. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- .../Scripts/Audio/AndroidRouteController.cs | 141 +++++++++++++----- Runtime/Scripts/Audio/PlatformAudio.cs | 31 ++-- .../Runtime/Agent/PlatformAudioController.cs | 16 +- .../Assets/Runtime/PlatformAudioController.cs | 16 +- 5 files changed, 142 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index cab944f4..5cb68b16 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,7 @@ 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. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call asserts the call session 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. +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. @@ -476,7 +476,7 @@ One consequence to design around on Android: while a call session is active on a 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. `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 SDK enters `MODE_IN_COMMUNICATION` and pins the route on enable, 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. +- **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. `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). diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 35b75fab..ca763805 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -14,8 +14,9 @@ namespace LiveKit /// AudioManager.getAvailableCommunicationDevices / /// setCommunicationDevice / clearCommunicationDevice. /// - /// The controller owns the voice-communication audio session while session audio is - /// enabled ( — i.e. while a call is in progress): + /// The controller owns the voice-communication audio session while it holds it — + /// session audio enabled ( — i.e. a call is in + /// progress) and acquired by a first trigger, see the lazy-acquisition paragraph: /// it enters MODE_IN_COMMUNICATION (saving and restoring the prior mode) and /// keeps the output route pinned to the best device — the sticky /// override while its device is still available, otherwise @@ -29,12 +30,28 @@ namespace LiveKit /// (re)starts — through , which like every other /// re-evaluation path pins nothing while session audio is disabled. /// - /// While session audio is disabled the session is handed back to the platform - /// (communication device cleared, prior mode restored), so the mode request and the - /// route pin cover the call rather than the lifetime of the instance. Enumeration, - /// the change listener and the poll thread stay alive regardless, so - /// and keep reporting the - /// platform's own routing while idle. + /// The session is acquired lazily: construction issues no setMode and no pin + /// even though session audio starts out enabled. The first trigger that needs the + /// session while it is enabled acquires it — an explicit + /// call with true, an + /// (which includes the + /// re-assert) or a + /// . Apps that disable session audio right after + /// construction therefore cause no audio-mode traffic at startup at all; the eager + /// constructor acquisition produced a take → pin → clear transient there, and with + /// a Bluetooth headset connected it started an asynchronous SCO activation only to + /// clear it mid-negotiation. The exposure is a receive-only app that never records, + /// never touches routing and never calls : it + /// no longer gets the mode and pin from construction, and opts back in by calling + /// with true at its call boundary. + /// + /// While the session is not held — session audio disabled, or enabled but nothing + /// has needed it yet — it belongs to the platform (communication device cleared, + /// prior mode restored on release), so the mode request and the route pin cover the + /// call rather than the lifetime of the instance. Enumeration, the change listener + /// and the poll thread stay alive regardless, so and + /// keep reporting the platform's own routing while + /// idle. /// /// Route changes are detected two ways, both required (device-verified in the /// sample hotfix this backend is hardened from, PR #364): @@ -94,6 +111,11 @@ internal sealed class AndroidRouteController : IRouteController // Session audio starts enabled, matching the documented default of // PlatformAudio.SetSessionAudioEnabled (uniform with iOS). private bool _sessionAudioEnabled = true; + // Whether this controller currently holds the call session (mode entered, pin + // allowed). Never true while _sessionAudioEnabled is false. Acquisition is + // lazy: despite the enabled default, nothing is taken until the first trigger + // that needs the session — see AcquireSessionIfNeeded and the class doc. + private bool _sessionAcquired; private int _savedAudioMode; private bool _audioModeSaved; // Set when an enter/leave transition failed (JNI unavailable, platform error) so @@ -145,10 +167,12 @@ private AndroidRouteController(PlatformAudio owner, IReadOnlyList ranked) lock (_gate) { _ranked = new List(ranked); + AcquireSessionIfNeeded(); } Reevaluate(); } @@ -205,6 +230,7 @@ public void SelectOutput(AudioDevice device) lock (_gate) { _stickyDeviceId = id; + AcquireSessionIfNeeded(); } Reevaluate(); } @@ -223,24 +249,35 @@ public void ClearOutputOverride() /// /// Takes or hands back the call audio session: enabling enters /// MODE_IN_COMMUNICATION and lets the policy pin the route, disabling - /// clears the pin and restores the mode this controller replaced. The ranked - /// preference survives the transition unconditionally. The sticky override - /// survives it only while its device stays available: the drop-on-disappear - /// bookkeeping keeps running while the session is disabled, so a device that - /// leaves the list between calls (a headset powered off) clears the override - /// for good, and the next call routes by the ranked preference. + /// clears the pin and restores the mode this controller replaced. An explicit + /// enable acquires the session even when the state was already enabled — the + /// lazy default means "enabled but nothing has needed the session yet" is a real + /// state, and this call is the documented way for a receive-only app to take the + /// session at its call boundary. Disabling before anything acquired the session + /// releases nothing: there is nothing to release, and issuing a clear/restore + /// there would be exactly the startup transient lazy acquisition removes. + /// The ranked preference survives the transition unconditionally. The sticky + /// override survives it only while its device stays available: the + /// drop-on-disappear bookkeeping keeps running while the session is disabled, so + /// a device that leaves the list between calls (a headset powered off) clears + /// the override for good, and the next call routes by the ranked preference. /// public void SetSessionAudioEnabled(bool enabled) { lock (_gate) { - if (_disposed || _sessionAudioEnabled == enabled) + if (_disposed || (_sessionAudioEnabled == enabled && _sessionAcquired == enabled)) return; _sessionAudioEnabled = enabled; if (enabled) - EnterCommunicationMode(); - else + { + AcquireSessionIfNeeded(); + } + else if (_sessionAcquired) + { + _sessionAcquired = false; LeaveCommunicationMode(); + } } // Re-evaluate outside the lock (Reevaluate takes it): pin the policy's target @@ -248,6 +285,20 @@ public void SetSessionAudioEnabled(bool enabled) Reevaluate(); } + // Called under _gate. The first routing trigger while session audio is enabled + // takes the call session (lazy acquisition — see the class doc); every later + // call is a no-op. Routing verbs express the intent to route, which is what the + // session exists for, so all of them funnel through here: an explicit enable, + // ApplyOutputPreference (including the StartRecording re-assert) and + // SelectOutput. + private void AcquireSessionIfNeeded() + { + if (_disposed || !_sessionAudioEnabled || _sessionAcquired) + return; + _sessionAcquired = true; + EnterCommunicationMode(); + } + /// /// Optional audio-focus request (AUDIOFOCUS_GAIN with voice-communication /// attributes) held while enabled. Off by default. Not exposed on the public @@ -298,10 +349,14 @@ public void Dispose() lock (_gate) { AbandonAudioFocus(); - // Same idempotent release as a session-audio disable: clearing a pin we - // no longer hold is a no-op, and the mode is only restored when this - // controller is the one that replaced it. - LeaveCommunicationMode(); + // Same idempotent release as a session-audio disable: only a session + // this controller holds (or a transition still pending retry) is handed + // back — a never-acquired session leaves the platform untouched, so + // creating and disposing an instance without a call issues no audio + // traffic at all. + if (_sessionAcquired || _sessionTransitionPending) + LeaveCommunicationMode(); + _sessionAcquired = false; _sessionAudioEnabled = false; } } @@ -317,13 +372,16 @@ public void Dispose() /// ranked is available, an existing pin is released so the OS default applies; /// kinds missing from the ranking are never auto-selected. /// - /// While session audio is disabled the pass is observation-only: it enumerates, - /// keeps the sticky bookkeeping current and still raises - /// , but issues no setCommunicationDevice / - /// clearCommunicationDevice and reports the platform's own communication device - /// as the selected one. Every trigger — the change listener, the poll thread and - /// the re-assert — runs through here, - /// so none of them can resurrect a released session. + /// While the session is not held — session audio disabled, or enabled but not + /// yet acquired — the pass is observation-only: it enumerates, keeps the sticky + /// bookkeeping current and still raises , but issues + /// no setCommunicationDevice / clearCommunicationDevice and reports the + /// platform's own communication device as the selected one. The change listener + /// and the poll thread run through here without acquiring anything, so neither + /// can resurrect a released session nor take a lazily-deferred one; the + /// re-assert acquires first (in + /// ) and then runs through here like the + /// rest. /// private void Reevaluate() { @@ -340,7 +398,7 @@ private void Reevaluate() // flip that happened in between is never undone. if (_sessionTransitionPending) { - if (_sessionAudioEnabled) + if (_sessionAcquired) EnterCommunicationMode(); else LeaveCommunicationMode(); @@ -388,14 +446,15 @@ private void Reevaluate() } int selectedId; - if (!_sessionAudioEnabled) + if (!_sessionAcquired) { - // No call in progress: report which device the platform would - // use for communication audio, and touch nothing. The target - // computed above is still worth running — it keeps the sticky - // override's "dropped once the device disappears" bookkeeping - // alive while idle — but it is only applied once a call - // re-enables the session. + // No session held (no call in progress, or nothing has + // needed the session yet): report which device the platform + // would use for communication audio, and touch nothing. The + // target computed above is still worth running — it keeps + // the sticky override's "dropped once the device disappears" + // bookkeeping alive while idle — but it is only applied once + // the session is acquired. selectedId = currentId; } else if (targetIndex >= 0) @@ -575,7 +634,7 @@ private void PollLoop() } // Both mode methods are called under _gate. The save/restore pairs up per - // enable -> disable transition and is idempotent in both directions: the prior + // acquire -> release transition and is idempotent in both directions: the prior // mode is only captured when we do not already hold one, and it is only restored // when it was actually read from the platform — a failed read must never turn // into an unconditional MODE_NORMAL, which would stomp a mode this app does not diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 81e65f42..cb8bd0b7 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -193,12 +193,16 @@ private void UpdateIosSessionState() /// / / /// ). /// - /// Session audio starts out enabled on every platform, so creating an instance - /// takes the platform's call audio session — on Android 12 (API 31) and newer - /// that means MODE_IN_COMMUNICATION plus the output route pin. Apps that - /// create PlatformAudio before their first call should call - /// with false right after - /// construction and enable it when a call starts. + /// Session audio starts out enabled on every platform, but on Android the call + /// session itself is acquired lazily: construction changes no audio mode and + /// pins no route — the first routing action while session audio is enabled + /// takes the session (an explicit enable, + /// an output preference or selection change, or the + /// re-assert). Apps that create PlatformAudio before their first call should + /// still call with false right + /// after construction and enable it when a call starts, so the call session + /// covers calls rather than the app's lifetime — on iOS that is also what keeps + /// the idle session in its music-friendly state. /// /// /// Thrown if the platform ADM could not be initialized (e.g., no audio devices, @@ -726,7 +730,9 @@ public IEnumerator StartRecording() // the app's MODE_IN_COMMUNICATION request — and with it the // communication-device pin — is only honored while the app has active // voice-communication capture, so the platform may have moved the route - // while it was un-owned. No-op on the other backends. + // while it was un-owned. On Android this is also where a lazily-deferred + // call session is first acquired (see SetSessionAudioEnabled). No-op on the + // other backends. _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly()); // Ensures this method is always a valid iterator even when the PLATFORM_ANDROID @@ -787,8 +793,15 @@ public void StopRecording() /// MODE_IN_COMMUNICATION and keeps the output route pinned per /// ; while disabled it holds neither, so the OS /// applies its normal routing and the call session covers the call rather than - /// the lifetime of this instance. Device enumeration and - /// keep working while disabled. Unlike iOS, + /// the lifetime of this instance. The session is acquired lazily: despite the + /// enabled default, creating the instance takes nothing — the first routing + /// action while enabled takes it (calling this method with true, even + /// when already enabled; changing / + /// ; ; or the + /// re-assert). A receive-only app that never + /// records and never touches routing therefore keeps the platform's own routing + /// until it calls this method with true at its call boundary. Device + /// enumeration and keep working while disabled. Unlike iOS, /// disabling does not stop the ADM: pair it with /// / at the call /// boundaries — an active capture without the session is what lets the platform diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index f527036e..ff7bb755 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -66,13 +66,15 @@ public bool Initialize() _platformAudio.DevicesChanged += OnDevicesChanged; AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; - // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: the platform's call audio session should only be held while a call is - // actually in progress — enabled means "in a call". Without this an app that - // keeps one ADM alive across calls would request communication mode and pin the - // call route from launch to quit. The caller must re-enable it when its call - // starts and disable it again when the call ends (MeetManager does so on - // join/leave, LiveKitAgentSession around Connect/EndSession). + // Session audio defaults to enabled, so declare "no call yet" right away: the + // platform's call audio session should only be held while a call is actually in + // progress — enabled means "in a call". On iOS this drops the session to its + // music-friendly idle state; on Android — where the session is only taken by + // the first action that needs it, never by construction — it keeps a later + // routing action from taking the call session outside a call. The caller must + // re-enable it when its call starts and disable it again when the call ends + // (MeetManager does so on join/leave, LiveKitAgentSession around + // Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; } diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index f527036e..ff7bb755 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -66,13 +66,15 @@ public bool Initialize() _platformAudio.DevicesChanged += OnDevicesChanged; AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; - // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: the platform's call audio session should only be held while a call is - // actually in progress — enabled means "in a call". Without this an app that - // keeps one ADM alive across calls would request communication mode and pin the - // call route from launch to quit. The caller must re-enable it when its call - // starts and disable it again when the call ends (MeetManager does so on - // join/leave, LiveKitAgentSession around Connect/EndSession). + // Session audio defaults to enabled, so declare "no call yet" right away: the + // platform's call audio session should only be held while a call is actually in + // progress — enabled means "in a call". On iOS this drops the session to its + // music-friendly idle state; on Android — where the session is only taken by + // the first action that needs it, never by construction — it keeps a later + // routing action from taking the call session outside a call. The caller must + // re-enable it when its call starts and disable it again when the call ends + // (MeetManager does so on join/leave, LiveKitAgentSession around + // Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; } From 6a53c652b536f99df96668b81c40ffa683ef8c11 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:23:44 +0200 Subject: [PATCH 34/35] Harden the PlatformAudio lifecycle (PAR-025) Every public member now throws ObjectDisposedException after Dispose() instead of reaching a disposed FfiHandle (or, on iOS, driving the audio session after it was handed back). DevicesChanged subscribe/unsubscribe and double-Dispose stay safe, as before. The constructor no longer leaks the FFI handle when route-controller creation fails after the handle exists: the post-handle work is wrapped so the handle (and a half-built controller) are disposed before the exception propagates, instead of waiting on the SafeHandle finalizer. The SelectOutput deferral while session audio is disabled deliberately gets no pending flag (PAR-019 keeps the surface frozen); the XML doc and the README routing section now spell out how a pre-call picker tracks its deferred choice through the existing IsSelected/DevicesChanged surface, including the drop-on-disappear case. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- Runtime/Scripts/Audio/PlatformAudio.cs | 88 ++++++++++++++++++++++---- Tests/PlayMode/PlatformAudioTests.cs | 35 ++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5cb68b16..ff35979c 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,7 @@ One consequence to design around on Android: while a call session is active on a 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. `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. +- **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). diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index cb8bd0b7..62381372 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -173,12 +173,32 @@ private void UpdateIosSessionState() /// /// Number of available recording (microphone) devices. /// - public int RecordingDeviceCount => _info.RecordingDeviceCount; + public int RecordingDeviceCount + { + get + { + ThrowIfDisposed(); + return _info.RecordingDeviceCount; + } + } /// /// Number of available playout (speaker) devices. /// - public int PlayoutDeviceCount => _info.PlayoutDeviceCount; + public int PlayoutDeviceCount + { + get + { + ThrowIfDisposed(); + return _info.PlayoutDeviceCount; + } + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(PlatformAudio)); + } /// /// Creates a new PlatformAudio instance, enabling the platform ADM. @@ -227,16 +247,27 @@ public PlatformAudio() Handle = FfiHandle.FromOwnedHandle(platformAudio.Handle); _info = platformAudio.Info; - _syncContext = SynchronizationContext.Current; - _routeController = CreateRouteController(); - _routeController.DevicesChanged += OnRouteControllerDevicesChanged; + try + { + _syncContext = SynchronizationContext.Current; + _routeController = CreateRouteController(); + _routeController.DevicesChanged += OnRouteControllerDevicesChanged; #if UNITY_IOS && !UNITY_EDITOR - // A fresh instance starts in the playout-only state (recording has not - // been started); this matches the plugin's post-configure default, so the - // call is a no-op unless an earlier instance left another state behind. - UpdateIosSessionState(); + // A fresh instance starts in the playout-only state (recording has not + // been started); this matches the plugin's post-configure default, so the + // call is a no-op unless an earlier instance left another state behind. + UpdateIosSessionState(); #endif + } + catch + { + // Without this, a route-controller failure would leak the FFI handle + // until the SafeHandle finalizer eventually reclaims it. + _routeController?.Dispose(); + Handle.Dispose(); + throw; + } Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices"); @@ -295,6 +326,7 @@ private IRouteController CreateRouteController() /// public (List Recording, List Playout) GetDevices() { + ThrowIfDisposed(); return _routeController.GetDevices(); } @@ -369,9 +401,14 @@ private IRouteController CreateRouteController() /// public IReadOnlyList OutputPreference { - get => _outputPreference.AsReadOnly(); + get + { + ThrowIfDisposed(); + return _outputPreference.AsReadOnly(); + } set { + ThrowIfDisposed(); if (value == null) throw new ArgumentNullException(nameof(value)); @@ -421,6 +458,7 @@ public bool IsSpeakerOutputPreferred { get { + ThrowIfDisposed(); var speaker = _outputPreference.IndexOf(AudioOutputKind.Speaker); var earpiece = _outputPreference.IndexOf(AudioOutputKind.Earpiece); if (speaker < 0) return false; @@ -428,6 +466,7 @@ public bool IsSpeakerOutputPreferred } set { + ThrowIfDisposed(); var first = value ? AudioOutputKind.Speaker : AudioOutputKind.Earpiece; var second = value ? AudioOutputKind.Earpiece : AudioOutputKind.Speaker; @@ -472,7 +511,16 @@ public bool IsSpeakerOutputPreferred /// session audio is disabled () the choice is /// only recorded — no pin is issued, and / /// keep reporting the platform's own route — until a - /// call enables the session. On iOS the + /// call enables the session. There is deliberately no pending flag for that + /// deferral: the app holds both inputs (its own SelectOutput call and its own + /// session-enable state), so a pre-call device picker should treat its last + /// selection as the pending choice and confirm application through the existing + /// surface — once the session is enabled and the pin lands, the device's + /// flips in / + /// . A deferred choice is dropped for good when its + /// device disappears before the session is enabled (the same drop-on-disappear + /// rule as an active pin), observable as the device leaving the playout list in + /// the same events. On iOS the /// OS owns output route selection and this method throws /// — present the system route picker /// (AVRoutePickerView) instead, or use / @@ -489,6 +537,7 @@ public bool IsSpeakerOutputPreferred /// public void SelectOutput(AudioDevice device) { + ThrowIfDisposed(); var (_, playout) = GetDevices(); foreach (var candidate in playout) { @@ -518,6 +567,7 @@ public void SelectOutput(AudioDevice device) /// public void ClearOutputOverride() { + ThrowIfDisposed(); _routeController.ClearOutputOverride(); } @@ -567,6 +617,7 @@ private void OnRouteControllerDevicesChanged( /// public void SetRecordingDevice(uint index) { + ThrowIfDisposed(); var (recording, _) = GetDevices(); if (index >= recording.Count) throw new InvalidOperationException($"Recording device index {index} out of range (max: {recording.Count - 1})"); @@ -590,6 +641,7 @@ public void SetRecordingDevice(uint index) /// public void SetRecordingDevice(string deviceId) { + ThrowIfDisposed(); using var request = FFIBridge.Instance.NewRequest(); request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle(); request.request.DeviceId = deviceId; @@ -616,6 +668,7 @@ public void SetRecordingDevice(string deviceId) /// public void SetPlayoutDevice(uint index) { + ThrowIfDisposed(); var (_, playout) = GetDevices(); if (index >= playout.Count) throw new InvalidOperationException($"Playout device index {index} out of range (max: {playout.Count - 1})"); @@ -639,6 +692,7 @@ public void SetPlayoutDevice(uint index) /// public void SetPlayoutDevice(string deviceId) { + ThrowIfDisposed(); using var request = FFIBridge.Instance.NewRequest(); request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle(); request.request.DeviceId = deviceId; @@ -670,6 +724,10 @@ public void SetPlayoutDevice(string deviceId) /// public IEnumerator StartRecording() { + // Iterator method: this throws on the first MoveNext, like the other + // exceptions below — Unity's StartCoroutine runs that synchronously. + ThrowIfDisposed(); + #if PLATFORM_ANDROID if (!Permission.HasUserAuthorizedPermission(Permission.Microphone)) { @@ -755,6 +813,7 @@ public IEnumerator StartRecording() /// public void StopRecording() { + ThrowIfDisposed(); using var request = FFIBridge.Instance.NewRequest(); request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle(); @@ -813,6 +872,7 @@ public void StopRecording() /// True while a call is active, false otherwise. public void SetSessionAudioEnabled(bool enabled) { + ThrowIfDisposed(); #if UNITY_IOS && !UNITY_EDITOR IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled); _iosSessionAudioEnabled = enabled; @@ -827,10 +887,15 @@ public void SetSessionAudioEnabled(bool enabled) /// /// When disposed, the platform ADM may be disabled if this was the last /// PlatformAudio instance. + /// + /// Disposing is idempotent. After disposal every public member throws + /// , except subscribing to / + /// unsubscribing from , which stays safe. /// public void Dispose() { if (_disposed) return; + _disposed = true; _routeController.DevicesChanged -= OnRouteControllerDevicesChanged; _routeController.Dispose(); Handle.Dispose(); @@ -845,7 +910,6 @@ public void Dispose() IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession(); #endif - _disposed = true; Utils.Debug("PlatformAudio disposed"); } } diff --git a/Tests/PlayMode/PlatformAudioTests.cs b/Tests/PlayMode/PlatformAudioTests.cs index 10b34752..b678d7c4 100644 --- a/Tests/PlayMode/PlatformAudioTests.cs +++ b/Tests/PlayMode/PlatformAudioTests.cs @@ -222,6 +222,41 @@ public IEnumerator DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose() yield break; } + [UnityTest] + public IEnumerator PublicMembers_AfterDispose_ThrowObjectDisposed() + { + var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore(); + platformAudio.Dispose(); + + Assert.Throws(() => _ = platformAudio.RecordingDeviceCount); + Assert.Throws(() => _ = platformAudio.PlayoutDeviceCount); + Assert.Throws(() => platformAudio.GetDevices()); + Assert.Throws(() => _ = platformAudio.OutputPreference); + Assert.Throws(() => + platformAudio.OutputPreference = new[] { AudioOutputKind.Speaker }); + Assert.Throws(() => _ = platformAudio.IsSpeakerOutputPreferred); + Assert.Throws(() => platformAudio.IsSpeakerOutputPreferred = true); + Assert.Throws(() => + platformAudio.SelectOutput(new AudioDevice { Index = 0, Name = "any" })); + Assert.Throws(() => platformAudio.ClearOutputOverride()); + Assert.Throws(() => platformAudio.SetRecordingDevice((uint)0)); + Assert.Throws(() => platformAudio.SetRecordingDevice("")); + Assert.Throws(() => platformAudio.SetPlayoutDevice((uint)0)); + Assert.Throws(() => platformAudio.SetPlayoutDevice("")); + Assert.Throws(() => platformAudio.StopRecording()); + Assert.Throws(() => platformAudio.SetSessionAudioEnabled(true)); + + // StartRecording is an iterator method: the guard throws on the first MoveNext. + var start = platformAudio.StartRecording(); + Assert.Throws(() => start.MoveNext()); + + // The guards must not break dispose idempotency or event safety + // (DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose covers the rest). + Assert.DoesNotThrow(() => platformAudio.Dispose()); + + yield break; + } + [UnityTest] public IEnumerator StartThenStopRecording_DoesNotThrow() { From c869e21da5d5c80aa1d9f0d52ddf85d6896852cd Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:49:21 +0200 Subject: [PATCH 35/35] Add the create-dispose-create PlayMode test for the phase-A gate (PAR-022) The last missing entry from PAR-022's desktop-runnable test list: a fresh PlatformAudio created after a full dispose in the same session must come up working (the native ADM ref-count has to return to zero cleanly), and the first instance's preference mutations must not leak into it. The other listed tests (preference roundtrip/precedence, bogus SelectOutput, event safety across dispose) already exist. Co-Authored-By: Claude Fable 5 --- Tests/PlayMode/PlatformAudioTests.cs | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Tests/PlayMode/PlatformAudioTests.cs b/Tests/PlayMode/PlatformAudioTests.cs index b678d7c4..4a487a1c 100644 --- a/Tests/PlayMode/PlatformAudioTests.cs +++ b/Tests/PlayMode/PlatformAudioTests.cs @@ -222,6 +222,35 @@ public IEnumerator DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose() yield break; } + [UnityTest] + public IEnumerator CreateDisposeCreate_OneSession_Works() + { + // The native ADM is ref-counted across PlatformAudio instances; after a full + // dispose the count must have returned to zero cleanly so a later instance in + // the same session comes up working (an app's second call after tearing the + // first one down). + var first = PlatformAudioTestHelper.TryCreateOrIgnore(); + first.OutputPreference = new[] { AudioOutputKind.Usb }; + first.Dispose(); + + using var second = new PlatformAudio(); + Assert.DoesNotThrow(() => second.GetDevices()); + + // Preference state is per instance: the first instance's mutation must not + // leak into the fresh one. + CollectionAssert.AreEqual( + new[] + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + }, + second.OutputPreference); + + yield break; + } + [UnityTest] public IEnumerator PublicMembers_AfterDispose_ThrowObjectDisposed() {