Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ One consequence to design around on Android: while a call session is active on a

Per-platform behavior:

- **Android 12+ (API 31)**: the full `OutputPreference` ranking applies — the SDK routes to the highest-ranked available kind and re-routes on device changes; kinds missing from the list are never auto-selected (when nothing ranked is available, the OS default route applies). `SelectOutput` pins a device from `GetDevices().Playout` as the communication device; the pin is dropped once that device disappears. While session audio is disabled, `SelectOutput` only records the choice — it is applied when the session is next enabled, and until then `GetDevices`/`DevicesChanged` keep reporting the platform's own route. `DevicesChanged` is raised on communication-device changes; changes that fire no OS event are caught by a poll with roughly 1.5 s of latency. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while session audio is enabled: the session is first taken by the first trigger that needs it while enabled — an explicit enable, an output preference/selection change, or capture starting; never by construction alone — the SDK then holds `MODE_IN_COMMUNICATION` with the route pinned, and clears the pin and restores the mode it replaced on disable, while enumeration and `DevicesChanged` stay live either way. Note: since Android 13 the OS only honors the app's communication-mode request — and with it the route pin — while the app has an active voice-communication capture, so keep the mic capture running for the whole call, even while muted with the track unpublished (see `PlatformAudioController` in the Meet sample); an active capture without an enabled session hands routing back to the platform, so pair the two at the call boundaries.
- **Android 12+ (API 31)**: the full `OutputPreference` ranking applies — the SDK routes to the highest-ranked available kind and re-routes on device changes; kinds missing from the list are never auto-selected (when nothing ranked is available, the OS default route applies). `SelectOutput` pins a device from `GetDevices().Playout` as the communication device; the pin is dropped once that device disappears. While session audio is disabled, `SelectOutput` only records the choice — it is applied when the session is next enabled, and until then `GetDevices`/`DevicesChanged` keep reporting the platform's own route. There is deliberately no pending flag for that deferral: a pre-call device picker should treat its own last `SelectOutput` call as the pending choice and confirm application via the `IsSelected` flip in `GetDevices`/`DevicesChanged` once the session is enabled; a deferred choice whose device disappears first is dropped for good (same rule as an active pin), observable as the device leaving the playout list. `DevicesChanged` is raised on communication-device changes; changes that fire no OS event are caught by a poll with roughly 1.5 s of latency. Requires the `MODIFY_AUDIO_SETTINGS` permission in your `AndroidManifest.xml`. Routing is asserted only while session audio is enabled: the session is first taken by the first trigger that needs it while enabled — an explicit enable, an output preference/selection change, or capture starting; never by construction alone — the SDK then holds `MODE_IN_COMMUNICATION` with the route pinned, and clears the pin and restores the mode it replaced on disable, while enumeration and `DevicesChanged` stay live either way. Note: since Android 13 the OS only honors the app's communication-mode request — and with it the route pin — while the app has an active voice-communication capture, so keep the mic capture running for the whole call, even while muted with the track unpublished (see `PlatformAudioController` in the Meet sample); an active capture without an enabled session hands routing back to the platform, so pair the two at the call boundaries.
- **Older Android**: no routing backend — `OutputPreference` is stored and round-trips but has no routing effect, and `SelectOutput` throws `NotSupportedException`. `DevicesChanged` is never raised.
- **iOS**: external devices (Bluetooth, wired) always take priority over the built-in outputs, so the Speaker/Earpiece relative order — `IsSpeakerOutputPreferred` — is the only part of the ranking with an effect. It decides where audio goes when no external device is connected, is applied through the audio session mode (never by overriding the output port), and takes effect immediately, including mid-call. `SelectOutput` throws `NotSupportedException` — the OS owns route selection on iOS; present the system route picker (`AVRoutePickerView`) instead. `GetDevices().Playout` is the audio session's current output route (iOS does not enumerate every reachable device), and `DevicesChanged` is raised when that route changes.
- **Desktop (Windows/macOS/Linux)**: output is selected per device — `SelectOutput` selects the playout device like `SetPlayoutDevice`, and the `OutputPreference` ranking has no routing effect. `DevicesChanged` is never raised (no hot-plug events yet).
Expand Down
88 changes: 76 additions & 12 deletions Runtime/Scripts/Audio/PlatformAudio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,32 @@ private void UpdateIosSessionState()
/// <summary>
/// Number of available recording (microphone) devices.
/// </summary>
public int RecordingDeviceCount => _info.RecordingDeviceCount;
public int RecordingDeviceCount
{
get
{
ThrowIfDisposed();
return _info.RecordingDeviceCount;
}
}

/// <summary>
/// Number of available playout (speaker) devices.
/// </summary>
public int PlayoutDeviceCount => _info.PlayoutDeviceCount;
public int PlayoutDeviceCount
{
get
{
ThrowIfDisposed();
return _info.PlayoutDeviceCount;
}
}

private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(PlatformAudio));
}

/// <summary>
/// Creates a new PlatformAudio instance, enabling the platform ADM.
Expand Down Expand Up @@ -227,16 +247,27 @@ public PlatformAudio()
Handle = FfiHandle.FromOwnedHandle(platformAudio.Handle);
_info = platformAudio.Info;

_syncContext = SynchronizationContext.Current;
_routeController = CreateRouteController();
_routeController.DevicesChanged += OnRouteControllerDevicesChanged;
try
{
_syncContext = SynchronizationContext.Current;
_routeController = CreateRouteController();
_routeController.DevicesChanged += OnRouteControllerDevicesChanged;

#if UNITY_IOS && !UNITY_EDITOR
// A fresh instance starts in the playout-only state (recording has not
// been started); this matches the plugin's post-configure default, so the
// call is a no-op unless an earlier instance left another state behind.
UpdateIosSessionState();
// A fresh instance starts in the playout-only state (recording has not
// been started); this matches the plugin's post-configure default, so the
// call is a no-op unless an earlier instance left another state behind.
UpdateIosSessionState();
#endif
}
catch
{
// Without this, a route-controller failure would leak the FFI handle
// until the SafeHandle finalizer eventually reclaims it.
_routeController?.Dispose();
Handle.Dispose();
throw;
}

Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices");

Expand Down Expand Up @@ -295,6 +326,7 @@ private IRouteController CreateRouteController()
/// </exception>
public (List<AudioDevice> Recording, List<AudioDevice> Playout) GetDevices()
{
ThrowIfDisposed();
return _routeController.GetDevices();
}

Expand Down Expand Up @@ -369,9 +401,14 @@ private IRouteController CreateRouteController()
/// </exception>
public IReadOnlyList<AudioOutputKind> OutputPreference
{
get => _outputPreference.AsReadOnly();
get
{
ThrowIfDisposed();
return _outputPreference.AsReadOnly();
}
set
{
ThrowIfDisposed();
if (value == null)
throw new ArgumentNullException(nameof(value));

Expand Down Expand Up @@ -421,13 +458,15 @@ public bool IsSpeakerOutputPreferred
{
get
{
ThrowIfDisposed();
var speaker = _outputPreference.IndexOf(AudioOutputKind.Speaker);
var earpiece = _outputPreference.IndexOf(AudioOutputKind.Earpiece);
if (speaker < 0) return false;
return earpiece < 0 || speaker < earpiece;
}
set
{
ThrowIfDisposed();
var first = value ? AudioOutputKind.Speaker : AudioOutputKind.Earpiece;
var second = value ? AudioOutputKind.Earpiece : AudioOutputKind.Speaker;

Expand Down Expand Up @@ -472,7 +511,16 @@ public bool IsSpeakerOutputPreferred
/// session audio is disabled (<see cref="SetSessionAudioEnabled"/>) the choice is
/// only recorded — no pin is issued, and <see cref="GetDevices"/> /
/// <see cref="DevicesChanged"/> keep reporting the platform's own route — until a
/// call enables the session. On iOS the
/// call enables the session. There is deliberately no pending flag for that
/// deferral: the app holds both inputs (its own SelectOutput call and its own
/// session-enable state), so a pre-call device picker should treat its last
/// selection as the pending choice and confirm application through the existing
/// surface — once the session is enabled and the pin lands, the device's
/// <see cref="AudioDevice.IsSelected"/> flips in <see cref="GetDevices"/> /
/// <see cref="DevicesChanged"/>. A deferred choice is dropped for good when its
/// device disappears before the session is enabled (the same drop-on-disappear
/// rule as an active pin), observable as the device leaving the playout list in
/// the same events. On iOS the
/// OS owns output route selection and this method throws
/// <see cref="NotSupportedException"/> — present the system route picker
/// (AVRoutePickerView) instead, or use <see cref="OutputPreference"/> /
Expand All @@ -489,6 +537,7 @@ public bool IsSpeakerOutputPreferred
/// </exception>
public void SelectOutput(AudioDevice device)
{
ThrowIfDisposed();
var (_, playout) = GetDevices();
foreach (var candidate in playout)
{
Expand Down Expand Up @@ -518,6 +567,7 @@ public void SelectOutput(AudioDevice device)
/// </summary>
public void ClearOutputOverride()
{
ThrowIfDisposed();
_routeController.ClearOutputOverride();
}

Expand Down Expand Up @@ -567,6 +617,7 @@ private void OnRouteControllerDevicesChanged(
/// </exception>
public void SetRecordingDevice(uint index)
{
ThrowIfDisposed();
var (recording, _) = GetDevices();
if (index >= recording.Count)
throw new InvalidOperationException($"Recording device index {index} out of range (max: {recording.Count - 1})");
Expand All @@ -590,6 +641,7 @@ public void SetRecordingDevice(uint index)
/// </exception>
public void SetRecordingDevice(string deviceId)
{
ThrowIfDisposed();
using var request = FFIBridge.Instance.NewRequest<SetRecordingDeviceRequest>();
request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle();
request.request.DeviceId = deviceId;
Expand All @@ -616,6 +668,7 @@ public void SetRecordingDevice(string deviceId)
/// </exception>
public void SetPlayoutDevice(uint index)
{
ThrowIfDisposed();
var (_, playout) = GetDevices();
if (index >= playout.Count)
throw new InvalidOperationException($"Playout device index {index} out of range (max: {playout.Count - 1})");
Expand All @@ -639,6 +692,7 @@ public void SetPlayoutDevice(uint index)
/// </exception>
public void SetPlayoutDevice(string deviceId)
{
ThrowIfDisposed();
using var request = FFIBridge.Instance.NewRequest<SetPlayoutDeviceRequest>();
request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle();
request.request.DeviceId = deviceId;
Expand Down Expand Up @@ -670,6 +724,10 @@ public void SetPlayoutDevice(string deviceId)
/// </exception>
public IEnumerator StartRecording()
{
// Iterator method: this throws on the first MoveNext, like the other
// exceptions below — Unity's StartCoroutine runs that synchronously.
ThrowIfDisposed();

#if PLATFORM_ANDROID
if (!Permission.HasUserAuthorizedPermission(Permission.Microphone))
{
Expand Down Expand Up @@ -755,6 +813,7 @@ public IEnumerator StartRecording()
/// </exception>
public void StopRecording()
{
ThrowIfDisposed();
using var request = FFIBridge.Instance.NewRequest<StopRecordingRequest>();
request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle();

Expand Down Expand Up @@ -813,6 +872,7 @@ public void StopRecording()
/// <param name="enabled">True while a call is active, false otherwise.</param>
public void SetSessionAudioEnabled(bool enabled)
{
ThrowIfDisposed();
#if UNITY_IOS && !UNITY_EDITOR
IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled);
_iosSessionAudioEnabled = enabled;
Expand All @@ -827,10 +887,15 @@ public void SetSessionAudioEnabled(bool enabled)
///
/// When disposed, the platform ADM may be disabled if this was the last
/// PlatformAudio instance.
///
/// Disposing is idempotent. After disposal every public member throws
/// <see cref="ObjectDisposedException"/>, except subscribing to /
/// unsubscribing from <see cref="DevicesChanged"/>, which stays safe.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_routeController.DevicesChanged -= OnRouteControllerDevicesChanged;
_routeController.Dispose();
Handle.Dispose();
Expand All @@ -845,7 +910,6 @@ public void Dispose()
IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession();
#endif

_disposed = true;
Utils.Debug("PlatformAudio disposed");
}
}
Expand Down
35 changes: 35 additions & 0 deletions Tests/PlayMode/PlatformAudioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,41 @@ public IEnumerator DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose()
yield break;
}

[UnityTest]
public IEnumerator PublicMembers_AfterDispose_ThrowObjectDisposed()
{
var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
platformAudio.Dispose();

Assert.Throws<ObjectDisposedException>(() => _ = platformAudio.RecordingDeviceCount);
Assert.Throws<ObjectDisposedException>(() => _ = platformAudio.PlayoutDeviceCount);
Assert.Throws<ObjectDisposedException>(() => platformAudio.GetDevices());
Assert.Throws<ObjectDisposedException>(() => _ = platformAudio.OutputPreference);
Assert.Throws<ObjectDisposedException>(() =>
platformAudio.OutputPreference = new[] { AudioOutputKind.Speaker });
Assert.Throws<ObjectDisposedException>(() => _ = platformAudio.IsSpeakerOutputPreferred);
Assert.Throws<ObjectDisposedException>(() => platformAudio.IsSpeakerOutputPreferred = true);
Assert.Throws<ObjectDisposedException>(() =>
platformAudio.SelectOutput(new AudioDevice { Index = 0, Name = "any" }));
Assert.Throws<ObjectDisposedException>(() => platformAudio.ClearOutputOverride());
Assert.Throws<ObjectDisposedException>(() => platformAudio.SetRecordingDevice((uint)0));
Assert.Throws<ObjectDisposedException>(() => platformAudio.SetRecordingDevice(""));
Assert.Throws<ObjectDisposedException>(() => platformAudio.SetPlayoutDevice((uint)0));
Assert.Throws<ObjectDisposedException>(() => platformAudio.SetPlayoutDevice(""));
Assert.Throws<ObjectDisposedException>(() => platformAudio.StopRecording());
Assert.Throws<ObjectDisposedException>(() => platformAudio.SetSessionAudioEnabled(true));

// StartRecording is an iterator method: the guard throws on the first MoveNext.
var start = platformAudio.StartRecording();
Assert.Throws<ObjectDisposedException>(() => start.MoveNext());

// The guards must not break dispose idempotency or event safety
// (DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose covers the rest).
Assert.DoesNotThrow(() => platformAudio.Dispose());

yield break;
}

[UnityTest]
public IEnumerator StartThenStopRecording_DoesNotThrow()
{
Expand Down
Loading