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; } + 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(); + 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 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,19 +69,25 @@ 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 - // 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 + // 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 - // 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 }, @@ -62,12 +98,99 @@ 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."); + } + + // 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() + { + IsPublished = false; + + if (_track != null && _room != null) + { + Debug.Log("[PlatformAudioController] Unpublishing microphone track."); + _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + } + _track = null; + +#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; + + // 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. @@ -80,6 +203,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) @@ -96,35 +228,334 @@ bool InitializePlatformAudio() } } - public void Dispose() +#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) { - IsPublished = false; + 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; + } + } - if (_track != null && _room != null) + 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) { - Debug.Log("[PlatformAudioController] Unpublishing microphone track."); - _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + int type = device != null ? device.Call("getType") : -1; + Debug.Log($"[PlatformAudioController] Communication device changed (type={type}); re-evaluating route."); + device?.Dispose(); + ApplyAndroidCommunicationRoute(); } - _track = null; + } - if (_platformAudio != null) + // 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) { - try + 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()) { - _platformAudio.StopRecording(); + Debug.Log("[PlatformAudioController] Route watchdog detected divergence; re-evaluating."); + ApplyAndroidCommunicationRoute(); } - catch (Exception e) + 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++) { - Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + 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; } + } - _source?.Dispose(); - _source = 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 + // 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 + _room = null; } } 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 @@ + 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}"); + +#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) @@ -150,8 +146,7 @@ private void OnDestroy() } CleanUpAllTracks(); _webCamTexture?.Stop(); - _platformAudioSource?.Dispose(); - _platformAudio?.Dispose(); + _platformAudioController?.Dispose(); _room?.Disconnect(); } @@ -249,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); @@ -360,7 +364,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 +553,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 +566,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 +610,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 +629,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 +703,11 @@ 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 (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 new file mode 100644 index 00000000..5bd99cce --- /dev/null +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -0,0 +1,561 @@ +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. 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. +public sealed class PlatformAudioController : IDisposable +{ + readonly string _trackName; + readonly AudioProcessingOptions _audioOptions; + + PlatformAudio _platformAudio; + PlatformAudioSource _source; + LocalAudioTrack _track; + 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; } + + 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() + { + 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 + // 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; + + // 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-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); + + 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."); + } + + // 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() + { + IsPublished = false; + + if (_track != null && _room != null) + { + Debug.Log("[PlatformAudioController] Unpublishing microphone track."); + _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + } + _track = null; + +#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; + + // 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. + 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; + } + } + + 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 + { + 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 + + _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: