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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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;