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 91c9756f..869c33ac 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 @@ -46,12 +58,15 @@ public IEnumerator Publish(Room room) Debug.Log("[PlatformAudioController] Starting platform recording."); yield return _platformAudio.StartRecording(); - // 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); + +#if UNITY_ANDROID && !UNITY_EDITOR + ApplyAndroidCommunicationRoute(true); +#endif - Debug.Log($"[PlatformAudioController] Publishing mic track '{MicTrackName}'..."); + _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 }, @@ -62,12 +77,46 @@ 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; + +#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. @@ -80,6 +129,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,31 +154,121 @@ 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; - - if (_track != null && _room != null) + switch (deviceType) { - Debug.Log("[PlatformAudioController] Unpublishing microphone track."); - _room.LocalParticipant.UnpublishTrack(_track, stopOnUnpublish: false); + 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; } - _track = null; + } - if (_platformAudio != null) + // 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) + { + try { - 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) { - _platformAudio.StopRecording(); + 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"); + } } - catch (Exception e) + else { - Debug.LogWarning($"[PlatformAudioController] Failed to stop recording: {e.Message}"); + // 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 - _source?.Dispose(); - _source = null; + public void Dispose() + { + Unpublish(); _platformAudio?.Dispose(); _platformAudio = 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..869c33ac --- /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(); + + +#if UNITY_ANDROID && !UNITY_EDITOR + ApplyAndroidCommunicationRoute(true); +#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."); + } + + // 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; + +#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. + 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; + } + } + + // 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) + { + 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: