diff --git a/README.md b/README.md index cab944f4..5cb68b16 100644 --- a/README.md +++ b/README.md @@ -457,7 +457,7 @@ platformAudio.StopRecording(); platformAudio.SetSessionAudioEnabled(false); ``` -While disabled, the SDK holds no call audio session: on iOS WebRTC's voice-processing unit is off and the session sits in a music-friendly idle state, and on Android 12+ the SDK requests neither `MODE_IN_COMMUNICATION` nor the output route pin, so the platform's normal routing applies. Device enumeration and `DevicesChanged` keep working on both, so a device picker can be populated before the first call. Leaving it enabled outside a call asserts the call session for as long as the instance lives, which is rarely what an app wants: the OS treats the app as being in a call, and the route stays pinned to the call policy instead of following the platform. +While disabled, the SDK holds no call audio session: on iOS WebRTC's voice-processing unit is off and the session sits in a music-friendly idle state, and on Android 12+ the SDK requests neither `MODE_IN_COMMUNICATION` nor the output route pin, so the platform's normal routing applies. On Android the session is also acquired lazily: constructing `PlatformAudio` issues no audio-mode traffic even though session audio starts enabled — the first action that needs the session while it is enabled takes it (enabling it explicitly, changing the output preference or selection, or starting capture) — so the disable-right-after-creation pattern above is completely silent at startup. Device enumeration and `DevicesChanged` keep working on both platforms, so a device picker can be populated before the first call. Leaving session audio enabled outside a call asserts the call session (on Android: from the first routing action on) for as long as the instance lives, which is rarely what an app wants: the OS treats the app as being in a call, and the route stays pinned to the call policy instead of following the platform. Unity's own audio engine is a separate layer that the SDK does not touch, and it needs a little care from an app that plays its own audio (music, SFX) alongside calls. When an output device is added or removed, Unity reinitializes its engine, which **stops every `AudioSource`** — and it raises `AudioSettings.OnAudioConfigurationChanged` only afterwards, so by the time the app is notified there is nothing left playing to inspect. What should still be audible therefore has to be remembered from before the change and restarted in that callback. On Android the callback's `deviceWasChanged` argument is `false` even for a real device change, so it cannot be used to filter these events. The Meet sample's `PlatformAudioController` shows the whole pattern. @@ -476,7 +476,7 @@ One consequence to design around on Android: while a call session is active on a Per-platform behavior: -- **Android 12+ (API 31)**: the full `OutputPreference` ranking applies — the SDK routes to the highest-ranked available kind and re-routes on device changes; kinds missing from the list are never auto-selected (when nothing ranked is available, the OS default route applies). `SelectOutput` pins a device from `GetDevices().Playout` as the communication device; the pin is dropped once that device disappears. While session audio is disabled, `SelectOutput` only records the choice — it is applied when the session is next enabled, and until then `GetDevices`/`DevicesChanged` keep reporting the platform's own route. `DevicesChanged` is raised on communication-device changes; changes that fire no OS event are caught by a poll with roughly 1.5 s of latency. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while session audio is enabled: the SDK enters `MODE_IN_COMMUNICATION` and pins the route on enable, and clears the pin and restores the mode it replaced on disable, while enumeration and `DevicesChanged` stay live either way. Note: since Android 13 the OS only honors the app's communication-mode request — and with it the route pin — while the app has an active voice-communication capture, so keep the mic capture running for the whole call, even while muted with the track unpublished (see `PlatformAudioController` in the Meet sample); an active capture without an enabled session hands routing back to the platform, so pair the two at the call boundaries. +- **Android 12+ (API 31)**: the full `OutputPreference` ranking applies — the SDK routes to the highest-ranked available kind and re-routes on device changes; kinds missing from the list are never auto-selected (when nothing ranked is available, the OS default route applies). `SelectOutput` pins a device from `GetDevices().Playout` as the communication device; the pin is dropped once that device disappears. While session audio is disabled, `SelectOutput` only records the choice — it is applied when the session is next enabled, and until then `GetDevices`/`DevicesChanged` keep reporting the platform's own route. `DevicesChanged` is raised on communication-device changes; changes that fire no OS event are caught by a poll with roughly 1.5 s of latency. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while session audio is enabled: the session is first taken by the first trigger that needs it while enabled — an explicit enable, an output preference/selection change, or capture starting; never by construction alone — the SDK then holds `MODE_IN_COMMUNICATION` with the route pinned, and clears the pin and restores the mode it replaced on disable, while enumeration and `DevicesChanged` stay live either way. Note: since Android 13 the OS only honors the app's communication-mode request — and with it the route pin — while the app has an active voice-communication capture, so keep the mic capture running for the whole call, even while muted with the track unpublished (see `PlatformAudioController` in the Meet sample); an active capture without an enabled session hands routing back to the platform, so pair the two at the call boundaries. - **Older Android**: no routing backend — `OutputPreference` is stored and round-trips but has no routing effect, and `SelectOutput` throws `NotSupportedException`. `DevicesChanged` is never raised. - **iOS**: external devices (Bluetooth, wired) always take priority over the built-in outputs, so the Speaker/Earpiece relative order — `IsSpeakerOutputPreferred` — is the only part of the ranking with an effect. It decides where audio goes when no external device is connected, is applied through the audio session mode (never by overriding the output port), and takes effect immediately, including mid-call. `SelectOutput` throws `NotSupportedException` — the OS owns route selection on iOS; present the system route picker (`AVRoutePickerView`) instead. `GetDevices().Playout` is the audio session's current output route (iOS does not enumerate every reachable device), and `DevicesChanged` is raised when that route changes. - **Desktop (Windows/macOS/Linux)**: output is selected per device — `SelectOutput` selects the playout device like `SetPlayoutDevice`, and the `OutputPreference` ranking has no routing effect. `DevicesChanged` is never raised (no hot-plug events yet). diff --git a/Runtime/Scripts/Audio/AndroidRouteController.cs b/Runtime/Scripts/Audio/AndroidRouteController.cs index 35b75fab..ca763805 100644 --- a/Runtime/Scripts/Audio/AndroidRouteController.cs +++ b/Runtime/Scripts/Audio/AndroidRouteController.cs @@ -14,8 +14,9 @@ namespace LiveKit /// AudioManager.getAvailableCommunicationDevices / /// setCommunicationDevice / clearCommunicationDevice. /// - /// The controller owns the voice-communication audio session while session audio is - /// enabled ( — i.e. while a call is in progress): + /// The controller owns the voice-communication audio session while it holds it — + /// session audio enabled ( — i.e. a call is in + /// progress) and acquired by a first trigger, see the lazy-acquisition paragraph: /// it enters MODE_IN_COMMUNICATION (saving and restoring the prior mode) and /// keeps the output route pinned to the best device — the sticky /// override while its device is still available, otherwise @@ -29,12 +30,28 @@ namespace LiveKit /// (re)starts — through , which like every other /// re-evaluation path pins nothing while session audio is disabled. /// - /// While session audio is disabled the session is handed back to the platform - /// (communication device cleared, prior mode restored), so the mode request and the - /// route pin cover the call rather than the lifetime of the instance. Enumeration, - /// the change listener and the poll thread stay alive regardless, so - /// and keep reporting the - /// platform's own routing while idle. + /// The session is acquired lazily: construction issues no setMode and no pin + /// even though session audio starts out enabled. The first trigger that needs the + /// session while it is enabled acquires it — an explicit + /// call with true, an + /// (which includes the + /// re-assert) or a + /// . Apps that disable session audio right after + /// construction therefore cause no audio-mode traffic at startup at all; the eager + /// constructor acquisition produced a take → pin → clear transient there, and with + /// a Bluetooth headset connected it started an asynchronous SCO activation only to + /// clear it mid-negotiation. The exposure is a receive-only app that never records, + /// never touches routing and never calls : it + /// no longer gets the mode and pin from construction, and opts back in by calling + /// with true at its call boundary. + /// + /// While the session is not held — session audio disabled, or enabled but nothing + /// has needed it yet — it belongs to the platform (communication device cleared, + /// prior mode restored on release), so the mode request and the route pin cover the + /// call rather than the lifetime of the instance. Enumeration, the change listener + /// and the poll thread stay alive regardless, so and + /// keep reporting the platform's own routing while + /// idle. /// /// Route changes are detected two ways, both required (device-verified in the /// sample hotfix this backend is hardened from, PR #364): @@ -94,6 +111,11 @@ internal sealed class AndroidRouteController : IRouteController // Session audio starts enabled, matching the documented default of // PlatformAudio.SetSessionAudioEnabled (uniform with iOS). private bool _sessionAudioEnabled = true; + // Whether this controller currently holds the call session (mode entered, pin + // allowed). Never true while _sessionAudioEnabled is false. Acquisition is + // lazy: despite the enabled default, nothing is taken until the first trigger + // that needs the session — see AcquireSessionIfNeeded and the class doc. + private bool _sessionAcquired; private int _savedAudioMode; private bool _audioModeSaved; // Set when an enter/leave transition failed (JNI unavailable, platform error) so @@ -145,10 +167,12 @@ private AndroidRouteController(PlatformAudio owner, IReadOnlyList ranked) lock (_gate) { _ranked = new List(ranked); + AcquireSessionIfNeeded(); } Reevaluate(); } @@ -205,6 +230,7 @@ public void SelectOutput(AudioDevice device) lock (_gate) { _stickyDeviceId = id; + AcquireSessionIfNeeded(); } Reevaluate(); } @@ -223,24 +249,35 @@ public void ClearOutputOverride() /// /// Takes or hands back the call audio session: enabling enters /// MODE_IN_COMMUNICATION and lets the policy pin the route, disabling - /// clears the pin and restores the mode this controller replaced. The ranked - /// preference survives the transition unconditionally. The sticky override - /// survives it only while its device stays available: the drop-on-disappear - /// bookkeeping keeps running while the session is disabled, so a device that - /// leaves the list between calls (a headset powered off) clears the override - /// for good, and the next call routes by the ranked preference. + /// clears the pin and restores the mode this controller replaced. An explicit + /// enable acquires the session even when the state was already enabled — the + /// lazy default means "enabled but nothing has needed the session yet" is a real + /// state, and this call is the documented way for a receive-only app to take the + /// session at its call boundary. Disabling before anything acquired the session + /// releases nothing: there is nothing to release, and issuing a clear/restore + /// there would be exactly the startup transient lazy acquisition removes. + /// The ranked preference survives the transition unconditionally. The sticky + /// override survives it only while its device stays available: the + /// drop-on-disappear bookkeeping keeps running while the session is disabled, so + /// a device that leaves the list between calls (a headset powered off) clears + /// the override for good, and the next call routes by the ranked preference. /// public void SetSessionAudioEnabled(bool enabled) { lock (_gate) { - if (_disposed || _sessionAudioEnabled == enabled) + if (_disposed || (_sessionAudioEnabled == enabled && _sessionAcquired == enabled)) return; _sessionAudioEnabled = enabled; if (enabled) - EnterCommunicationMode(); - else + { + AcquireSessionIfNeeded(); + } + else if (_sessionAcquired) + { + _sessionAcquired = false; LeaveCommunicationMode(); + } } // Re-evaluate outside the lock (Reevaluate takes it): pin the policy's target @@ -248,6 +285,20 @@ public void SetSessionAudioEnabled(bool enabled) Reevaluate(); } + // Called under _gate. The first routing trigger while session audio is enabled + // takes the call session (lazy acquisition — see the class doc); every later + // call is a no-op. Routing verbs express the intent to route, which is what the + // session exists for, so all of them funnel through here: an explicit enable, + // ApplyOutputPreference (including the StartRecording re-assert) and + // SelectOutput. + private void AcquireSessionIfNeeded() + { + if (_disposed || !_sessionAudioEnabled || _sessionAcquired) + return; + _sessionAcquired = true; + EnterCommunicationMode(); + } + /// /// Optional audio-focus request (AUDIOFOCUS_GAIN with voice-communication /// attributes) held while enabled. Off by default. Not exposed on the public @@ -298,10 +349,14 @@ public void Dispose() lock (_gate) { AbandonAudioFocus(); - // Same idempotent release as a session-audio disable: clearing a pin we - // no longer hold is a no-op, and the mode is only restored when this - // controller is the one that replaced it. - LeaveCommunicationMode(); + // Same idempotent release as a session-audio disable: only a session + // this controller holds (or a transition still pending retry) is handed + // back — a never-acquired session leaves the platform untouched, so + // creating and disposing an instance without a call issues no audio + // traffic at all. + if (_sessionAcquired || _sessionTransitionPending) + LeaveCommunicationMode(); + _sessionAcquired = false; _sessionAudioEnabled = false; } } @@ -317,13 +372,16 @@ public void Dispose() /// ranked is available, an existing pin is released so the OS default applies; /// kinds missing from the ranking are never auto-selected. /// - /// While session audio is disabled the pass is observation-only: it enumerates, - /// keeps the sticky bookkeeping current and still raises - /// , but issues no setCommunicationDevice / - /// clearCommunicationDevice and reports the platform's own communication device - /// as the selected one. Every trigger — the change listener, the poll thread and - /// the re-assert — runs through here, - /// so none of them can resurrect a released session. + /// While the session is not held — session audio disabled, or enabled but not + /// yet acquired — the pass is observation-only: it enumerates, keeps the sticky + /// bookkeeping current and still raises , but issues + /// no setCommunicationDevice / clearCommunicationDevice and reports the + /// platform's own communication device as the selected one. The change listener + /// and the poll thread run through here without acquiring anything, so neither + /// can resurrect a released session nor take a lazily-deferred one; the + /// re-assert acquires first (in + /// ) and then runs through here like the + /// rest. /// private void Reevaluate() { @@ -340,7 +398,7 @@ private void Reevaluate() // flip that happened in between is never undone. if (_sessionTransitionPending) { - if (_sessionAudioEnabled) + if (_sessionAcquired) EnterCommunicationMode(); else LeaveCommunicationMode(); @@ -388,14 +446,15 @@ private void Reevaluate() } int selectedId; - if (!_sessionAudioEnabled) + if (!_sessionAcquired) { - // No call in progress: report which device the platform would - // use for communication audio, and touch nothing. The target - // computed above is still worth running — it keeps the sticky - // override's "dropped once the device disappears" bookkeeping - // alive while idle — but it is only applied once a call - // re-enables the session. + // No session held (no call in progress, or nothing has + // needed the session yet): report which device the platform + // would use for communication audio, and touch nothing. The + // target computed above is still worth running — it keeps + // the sticky override's "dropped once the device disappears" + // bookkeeping alive while idle — but it is only applied once + // the session is acquired. selectedId = currentId; } else if (targetIndex >= 0) @@ -575,7 +634,7 @@ private void PollLoop() } // Both mode methods are called under _gate. The save/restore pairs up per - // enable -> disable transition and is idempotent in both directions: the prior + // acquire -> release transition and is idempotent in both directions: the prior // mode is only captured when we do not already hold one, and it is only restored // when it was actually read from the platform — a failed read must never turn // into an unconditional MODE_NORMAL, which would stomp a mode this app does not diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index 81e65f42..cb8bd0b7 100644 --- a/Runtime/Scripts/Audio/PlatformAudio.cs +++ b/Runtime/Scripts/Audio/PlatformAudio.cs @@ -193,12 +193,16 @@ private void UpdateIosSessionState() /// / / /// ). /// - /// Session audio starts out enabled on every platform, so creating an instance - /// takes the platform's call audio session — on Android 12 (API 31) and newer - /// that means MODE_IN_COMMUNICATION plus the output route pin. Apps that - /// create PlatformAudio before their first call should call - /// with false right after - /// construction and enable it when a call starts. + /// Session audio starts out enabled on every platform, but on Android the call + /// session itself is acquired lazily: construction changes no audio mode and + /// pins no route — the first routing action while session audio is enabled + /// takes the session (an explicit enable, + /// an output preference or selection change, or the + /// re-assert). Apps that create PlatformAudio before their first call should + /// still call with false right + /// after construction and enable it when a call starts, so the call session + /// covers calls rather than the app's lifetime — on iOS that is also what keeps + /// the idle session in its music-friendly state. /// /// /// Thrown if the platform ADM could not be initialized (e.g., no audio devices, @@ -726,7 +730,9 @@ public IEnumerator StartRecording() // the app's MODE_IN_COMMUNICATION request — and with it the // communication-device pin — is only honored while the app has active // voice-communication capture, so the platform may have moved the route - // while it was un-owned. No-op on the other backends. + // while it was un-owned. On Android this is also where a lazily-deferred + // call session is first acquired (see SetSessionAudioEnabled). No-op on the + // other backends. _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly()); // Ensures this method is always a valid iterator even when the PLATFORM_ANDROID @@ -787,8 +793,15 @@ public void StopRecording() /// MODE_IN_COMMUNICATION and keeps the output route pinned per /// ; while disabled it holds neither, so the OS /// applies its normal routing and the call session covers the call rather than - /// the lifetime of this instance. Device enumeration and - /// keep working while disabled. Unlike iOS, + /// the lifetime of this instance. The session is acquired lazily: despite the + /// enabled default, creating the instance takes nothing — the first routing + /// action while enabled takes it (calling this method with true, even + /// when already enabled; changing / + /// ; ; or the + /// re-assert). A receive-only app that never + /// records and never touches routing therefore keeps the platform's own routing + /// until it calls this method with true at its call boundary. Device + /// enumeration and keep working while disabled. Unlike iOS, /// disabling does not stop the ADM: pair it with /// / at the call /// boundaries — an active capture without the session is what lets the platform diff --git a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs index f527036e..ff7bb755 100644 --- a/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs +++ b/Samples~/Agents/Assets/Runtime/Agent/PlatformAudioController.cs @@ -66,13 +66,15 @@ public bool Initialize() _platformAudio.DevicesChanged += OnDevicesChanged; AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; - // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: the platform's call audio session should only be held while a call is - // actually in progress — enabled means "in a call". Without this an app that - // keeps one ADM alive across calls would request communication mode and pin the - // call route from launch to quit. The caller must re-enable it when its call - // starts and disable it again when the call ends (MeetManager does so on - // join/leave, LiveKitAgentSession around Connect/EndSession). + // Session audio defaults to enabled, so declare "no call yet" right away: the + // platform's call audio session should only be held while a call is actually in + // progress — enabled means "in a call". On iOS this drops the session to its + // music-friendly idle state; on Android — where the session is only taken by + // the first action that needs it, never by construction — it keeps a later + // routing action from taking the call session outside a call. The caller must + // re-enable it when its call starts and disable it again when the call ends + // (MeetManager does so on join/leave, LiveKitAgentSession around + // Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; } diff --git a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs index f527036e..ff7bb755 100644 --- a/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs +++ b/Samples~/Meet/Assets/Runtime/PlatformAudioController.cs @@ -66,13 +66,15 @@ public bool Initialize() _platformAudio.DevicesChanged += OnDevicesChanged; AudioSettings.OnAudioConfigurationChanged += OnUnityAudioConfigurationChanged; - // Session audio is enabled when PlatformAudio is created, so hand it straight - // back: the platform's call audio session should only be held while a call is - // actually in progress — enabled means "in a call". Without this an app that - // keeps one ADM alive across calls would request communication mode and pin the - // call route from launch to quit. The caller must re-enable it when its call - // starts and disable it again when the call ends (MeetManager does so on - // join/leave, LiveKitAgentSession around Connect/EndSession). + // Session audio defaults to enabled, so declare "no call yet" right away: the + // platform's call audio session should only be held while a call is actually in + // progress — enabled means "in a call". On iOS this drops the session to its + // music-friendly idle state; on Android — where the session is only taken by + // the first action that needs it, never by construction — it keeps a later + // routing action from taking the call session outside a call. The caller must + // re-enable it when its call starts and disable it again when the call ends + // (MeetManager does so on join/leave, LiveKitAgentSession around + // Connect/EndSession). _platformAudio.SetSessionAudioEnabled(false); return true; }