diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs
index 7c113e20..414ef160 100644
--- a/Runtime/Scripts/Audio/PlatformAudio.cs
+++ b/Runtime/Scripts/Audio/PlatformAudio.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Threading;
using LiveKit.Proto;
using LiveKit.Internal;
using LiveKit.Internal.FFI.Requests;
@@ -34,6 +35,31 @@ internal static class IOSAudioSessionHelper
}
#endif
+ ///
+ /// The kind of audio output device, used for ranked routing policies on mobile
+ /// platforms (see ).
+ ///
+ /// The numeric values mirror the planned FFI protocol enum (AudioDeviceKind) one-to-one
+ /// so a future FFI-backed implementation maps without translation. Do not renumber.
+ ///
+ public enum AudioOutputKind
+ {
+ /// The platform did not report a device type.
+ Unknown = 0,
+ /// The phone's built-in earpiece (receiver).
+ Earpiece = 1,
+ /// The built-in loudspeaker.
+ Speaker = 2,
+ /// A wired headset or headphones.
+ WiredHeadset = 3,
+ /// A Bluetooth audio device.
+ Bluetooth = 4,
+ /// A USB audio device.
+ Usb = 5,
+ /// A hearing aid.
+ HearingAid = 6,
+ }
+
///
/// Information about an audio device (microphone or speaker).
///
@@ -49,6 +75,17 @@ public struct AudioDevice
/// over index for device selection.
///
public string Guid;
+ ///
+ /// The kind of output this device represents.
+ /// where the platform does not report a type — currently all devices: no routing
+ /// backend classifies devices yet.
+ ///
+ public AudioOutputKind Kind;
+ ///
+ /// Whether this device is the active output route. Only meaningful once a platform
+ /// routing backend reports selection state — currently always false.
+ ///
+ public bool IsSelected;
}
///
@@ -73,8 +110,19 @@ public sealed class PlatformAudio : IDisposable
{
internal readonly FfiHandle Handle;
private readonly PlatformAudioInfo _info;
+ private readonly IRouteController _routeController;
+ private readonly SynchronizationContext _syncContext;
+ private List _outputPreference = new List(DefaultOutputPreference);
private bool _disposed = false;
+ private static readonly AudioOutputKind[] DefaultOutputPreference =
+ {
+ AudioOutputKind.Bluetooth,
+ AudioOutputKind.WiredHeadset,
+ AudioOutputKind.Speaker,
+ AudioOutputKind.Earpiece,
+ };
+
///
/// Number of available recording (microphone) devices.
///
@@ -118,9 +166,24 @@ public PlatformAudio()
Handle = FfiHandle.FromOwnedHandle(platformAudio.Handle);
_info = platformAudio.Info;
+ _syncContext = SynchronizationContext.Current;
+ _routeController = CreateRouteController();
+ _routeController.DevicesChanged += OnRouteControllerDevicesChanged;
+
Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices");
}
+ private IRouteController CreateRouteController()
+ {
+#if UNITY_ANDROID && !UNITY_EDITOR
+ return new UnsupportedRouteController(this, "Android");
+#elif UNITY_IOS && !UNITY_EDITOR
+ return new UnsupportedRouteController(this, "iOS");
+#else
+ return new DesktopRouteController(this);
+#endif
+ }
+
///
/// Gets the lists of available recording and playout devices.
///
@@ -146,6 +209,16 @@ public PlatformAudio()
/// Thrown if device enumeration failed.
///
public (List Recording, List Playout) GetDevices()
+ {
+ return _routeController.GetDevices();
+ }
+
+ ///
+ /// Device enumeration through the FFI, shared by the route controllers.
+ /// and are not
+ /// reported by the FFI and stay at their defaults (Unknown / false).
+ ///
+ internal (List Recording, List Playout) GetDevicesViaFfi()
{
using var request = FFIBridge.Instance.NewRequest();
request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle();
@@ -179,6 +252,199 @@ public PlatformAudio()
return (recording, playout);
}
+ ///
+ /// Ranked automatic output routing policy, most preferred first. When no explicit
+ /// output override is active (), the platform routes to
+ /// the highest-ranked kind that has a connected device.
+ ///
+ /// Default: Bluetooth > WiredHeadset > Speaker > Earpiece.
+ ///
+ /// Precedence with : this list is the single
+ /// source of truth; the bool is convenience sugar that only rewrites the relative
+ /// order of and
+ /// inside this list, and reading the bool
+ /// reads their current relative order. There is no separate speaker-preference state.
+ ///
+ /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority
+ /// over the built-in outputs, so the Speaker/Earpiece relative order — i.e.
+ /// — is the only part of the ranking with an
+ /// effect. On Android the full ranking applies. On desktop, output is selected
+ /// per device ( / )
+ /// and the ranking has no routing effect. The mobile routing backends are not
+ /// implemented yet in this version: on Android and iOS the value is currently
+ /// stored and round-trips, but has no routing effect either.
+ ///
+ /// Thrown if set to null.
+ ///
+ /// Thrown if the list contains or duplicates.
+ ///
+ public IReadOnlyList OutputPreference
+ {
+ get => _outputPreference.AsReadOnly();
+ set
+ {
+ if (value == null)
+ throw new ArgumentNullException(nameof(value));
+
+ var ranked = new List(value.Count);
+ foreach (var kind in value)
+ {
+ if (kind == AudioOutputKind.Unknown)
+ throw new ArgumentException(
+ "OutputPreference cannot contain AudioOutputKind.Unknown", nameof(value));
+ if (ranked.Contains(kind))
+ throw new ArgumentException(
+ $"OutputPreference contains {kind} more than once", nameof(value));
+ ranked.Add(kind);
+ }
+
+ _outputPreference = ranked;
+ _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly());
+ }
+ }
+
+ ///
+ /// Whether the loudspeaker is preferred over the earpiece for automatic routing.
+ ///
+ /// Precedence with : the list is the single source of
+ /// truth; this bool is convenience sugar that only rewrites the relative order of
+ /// and
+ /// inside , and reading it reads their current
+ /// relative order. There is no separate speaker-preference state. Reading returns
+ /// true when Speaker ranks ahead of Earpiece (or Earpiece is absent), false when
+ /// Speaker is absent. Setting reorders the pair in place at the position of
+ /// whichever currently ranks first, inserting a missing kind next to the present
+ /// one (or appending both when neither is listed) so the value round-trips.
+ ///
+ /// Platform notes: on iOS, external devices (Bluetooth, wired) always take priority
+ /// over the built-in outputs, so this bool is the only part of the ranking with an
+ /// effect. On Android the full ranking applies. On desktop, output is selected
+ /// per device ( / )
+ /// and the ranking has no routing effect. The mobile routing backends are not
+ /// implemented yet in this version: on Android and iOS the value is currently
+ /// stored and round-trips, but has no routing effect either.
+ ///
+ public bool IsSpeakerOutputPreferred
+ {
+ get
+ {
+ var speaker = _outputPreference.IndexOf(AudioOutputKind.Speaker);
+ var earpiece = _outputPreference.IndexOf(AudioOutputKind.Earpiece);
+ if (speaker < 0) return false;
+ return earpiece < 0 || speaker < earpiece;
+ }
+ set
+ {
+ var first = value ? AudioOutputKind.Speaker : AudioOutputKind.Earpiece;
+ var second = value ? AudioOutputKind.Earpiece : AudioOutputKind.Speaker;
+
+ var reordered = new List(_outputPreference.Count + 2);
+ var pairInserted = false;
+ foreach (var kind in _outputPreference)
+ {
+ if (kind == AudioOutputKind.Speaker || kind == AudioOutputKind.Earpiece)
+ {
+ if (!pairInserted)
+ {
+ reordered.Add(first);
+ reordered.Add(second);
+ pairInserted = true;
+ }
+ continue;
+ }
+ reordered.Add(kind);
+ }
+ if (!pairInserted)
+ {
+ reordered.Add(first);
+ reordered.Add(second);
+ }
+
+ _outputPreference = reordered;
+ _routeController.ApplyOutputPreference(_outputPreference.AsReadOnly());
+ }
+ }
+
+ ///
+ /// Routes audio output to the given device as a sticky override of the automatic
+ /// policy: the route stays on the device until
+ /// is called. The device is matched against the
+ /// current playout list by
+ /// when set, otherwise by index and name.
+ ///
+ /// Platform notes: on desktop this selects the device like
+ /// . On Android and iOS the routing backends
+ /// are not implemented yet in this version and this method throws
+ /// .
+ ///
+ /// A playout device from .
+ ///
+ /// Thrown if the device does not match any current playout device.
+ ///
+ ///
+ /// Thrown on Android and iOS, where no routing backend exists yet.
+ ///
+ public void SelectOutput(AudioDevice device)
+ {
+ var (_, playout) = GetDevices();
+ foreach (var candidate in playout)
+ {
+ var matches = !string.IsNullOrEmpty(device.Guid)
+ ? candidate.Guid == device.Guid
+ : candidate.Index == device.Index && candidate.Name == device.Name;
+ if (!matches) continue;
+
+ _routeController.SelectOutput(candidate);
+ return;
+ }
+
+ throw new ArgumentException(
+ $"Device '{device.Name}' (index {device.Index}, guid {device.Guid ?? "none"}) " +
+ "is not a current playout device", nameof(device));
+ }
+
+ ///
+ /// Clears the sticky override set by so the automatic
+ /// policy applies again.
+ ///
+ /// Platform notes: on desktop there is no automatic policy to fall back to yet, so
+ /// clearing keeps the currently selected device (no-op). On Android and iOS no
+ /// override can exist yet ( throws), so this is a no-op
+ /// there as well.
+ ///
+ public void ClearOutputOverride()
+ {
+ _routeController.ClearOutputOverride();
+ }
+
+ ///
+ /// Raised when the set of available audio devices changes, with the current playout
+ /// and recording device lists. Raised on the Unity main thread.
+ ///
+ /// No implementation raises this event yet in this version: desktop hot-plug events
+ /// and the mobile routing backends that produce it are not implemented. Subscribing
+ /// and unsubscribing is safe at any time, including after .
+ ///
+ public event Action, IReadOnlyList> DevicesChanged;
+
+ private void OnRouteControllerDevicesChanged(
+ IReadOnlyList playout, IReadOnlyList recording)
+ {
+ if (_disposed) return;
+
+ if (_syncContext != null && _syncContext != SynchronizationContext.Current)
+ {
+ _syncContext.Post(_ =>
+ {
+ if (!_disposed)
+ DevicesChanged?.Invoke(playout, recording);
+ }, null);
+ return;
+ }
+
+ DevicesChanged?.Invoke(playout, recording);
+ }
+
///
/// Sets the recording device (microphone) by index.
///
@@ -363,6 +629,8 @@ public void StopRecording()
public void Dispose()
{
if (_disposed) return;
+ _routeController.DevicesChanged -= OnRouteControllerDevicesChanged;
+ _routeController.Dispose();
Handle.Dispose();
_disposed = true;
Utils.Debug("PlatformAudio disposed");
diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs
new file mode 100644
index 00000000..31d7861b
--- /dev/null
+++ b/Runtime/Scripts/Audio/RouteController.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+
+namespace LiveKit
+{
+ ///
+ /// Backend seam for audio output routing. registers one
+ /// implementation per platform and forwards its public routing API
+ /// (, ,
+ /// , ,
+ /// ) through it, so the plumbing can be swapped
+ /// per platform — and later wholesale for an FFI-backed implementation — without changing
+ /// a public signature.
+ ///
+ internal interface IRouteController : IDisposable
+ {
+ /// Snapshot of the current recording and playout device lists.
+ (List Recording, List Playout) GetDevices();
+
+ /// Applies the ranked automatic output policy, most preferred first.
+ void ApplyOutputPreference(IReadOnlyList ranked);
+
+ ///
+ /// Routes output to the given device as a sticky override of the automatic policy.
+ /// The device has already been validated against the current playout snapshot.
+ ///
+ void SelectOutput(AudioDevice device);
+
+ /// Clears the sticky override so the automatic policy applies again.
+ void ClearOutputOverride();
+
+ ///
+ /// Raised when the available devices change, with the current (playout, recording)
+ /// lists. May be raised from any thread; marshals it to
+ /// the Unity main thread before re-raising publicly.
+ ///
+ event Action, IReadOnlyList> DevicesChanged;
+ }
+
+ ///
+ /// Desktop routing backend: wraps the FFI device enumeration and per-device GUID
+ /// selection. Ranked-kind policy is not implemented on desktop (output is chosen per
+ /// device), and no desktop hot-plug events exist yet, so
+ /// is never raised.
+ ///
+ internal sealed class DesktopRouteController : IRouteController
+ {
+ private readonly PlatformAudio _owner;
+
+ public DesktopRouteController(PlatformAudio owner)
+ {
+ _owner = owner;
+ }
+
+ public (List Recording, List Playout) GetDevices()
+ {
+ return _owner.GetDevicesViaFfi();
+ }
+
+ public void ApplyOutputPreference(IReadOnlyList ranked)
+ {
+ // No routing effect on desktop: output is selected per device, not by kind.
+ }
+
+ public void SelectOutput(AudioDevice device)
+ {
+ if (!string.IsNullOrEmpty(device.Guid))
+ _owner.SetPlayoutDevice(device.Guid);
+ else
+ _owner.SetPlayoutDevice(device.Index);
+ }
+
+ public void ClearOutputOverride()
+ {
+ // No automatic policy to fall back to on desktop; the selected device stays.
+ }
+
+ public event Action, IReadOnlyList> DevicesChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+
+ ///
+ /// Placeholder backend for platforms whose routing implementation has not landed yet
+ /// (Android, iOS). Device snapshots still work through the FFI (a single placeholder
+ /// entry for the OS default input/output); the routing verbs throw or no-op as
+ /// documented on the public API.
+ ///
+ internal sealed class UnsupportedRouteController : IRouteController
+ {
+ private readonly PlatformAudio _owner;
+ private readonly string _platform;
+
+ public UnsupportedRouteController(PlatformAudio owner, string platform)
+ {
+ _owner = owner;
+ _platform = platform;
+ }
+
+ public (List Recording, List Playout) GetDevices()
+ {
+ return _owner.GetDevicesViaFfi();
+ }
+
+ public void ApplyOutputPreference(IReadOnlyList ranked)
+ {
+ // Stored by PlatformAudio; no routing effect until this platform's backend lands.
+ }
+
+ public void SelectOutput(AudioDevice device)
+ {
+ throw new NotSupportedException(
+ $"SelectOutput is not implemented on {_platform} yet");
+ }
+
+ public void ClearOutputOverride()
+ {
+ // No override can exist on this platform: SelectOutput throws.
+ }
+
+ public event Action, IReadOnlyList> DevicesChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/Runtime/Scripts/Audio/RouteController.cs.meta b/Runtime/Scripts/Audio/RouteController.cs.meta
new file mode 100644
index 00000000..82c5747b
--- /dev/null
+++ b/Runtime/Scripts/Audio/RouteController.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3a99955b361ca4e5aa765a7e6dfc9e73
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/PlayMode/PlatformAudioTests.cs b/Tests/PlayMode/PlatformAudioTests.cs
index 28b0ed7b..10b34752 100644
--- a/Tests/PlayMode/PlatformAudioTests.cs
+++ b/Tests/PlayMode/PlatformAudioTests.cs
@@ -102,6 +102,126 @@ public IEnumerator SetRecordingDeviceByIndex_OutOfRange_Throws()
yield break;
}
+ [UnityTest]
+ public IEnumerator OutputPreference_DefaultsAndRoundtrips()
+ {
+ using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
+
+ // Documented default ranking.
+ CollectionAssert.AreEqual(
+ new[]
+ {
+ AudioOutputKind.Bluetooth,
+ AudioOutputKind.WiredHeadset,
+ AudioOutputKind.Speaker,
+ AudioOutputKind.Earpiece,
+ },
+ platformAudio.OutputPreference);
+
+ // Set/get roundtrip preserves order and content.
+ var ranked = new[] { AudioOutputKind.Usb, AudioOutputKind.Speaker, AudioOutputKind.Bluetooth };
+ platformAudio.OutputPreference = ranked;
+ CollectionAssert.AreEqual(ranked, platformAudio.OutputPreference);
+
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator OutputPreference_RejectsInvalidLists()
+ {
+ using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
+
+ Assert.Throws(() => platformAudio.OutputPreference = null);
+ Assert.Throws(() =>
+ platformAudio.OutputPreference = new[] { AudioOutputKind.Unknown });
+ Assert.Throws(() =>
+ platformAudio.OutputPreference = new[] { AudioOutputKind.Speaker, AudioOutputKind.Speaker });
+
+ // A rejected assignment leaves the stored preference untouched.
+ CollectionAssert.AreEqual(
+ new[]
+ {
+ AudioOutputKind.Bluetooth,
+ AudioOutputKind.WiredHeadset,
+ AudioOutputKind.Speaker,
+ AudioOutputKind.Earpiece,
+ },
+ platformAudio.OutputPreference);
+
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator SpeakerPreference_BoolAndListOrderAreOneState()
+ {
+ using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
+
+ // Default ranking has Speaker ahead of Earpiece.
+ Assert.IsTrue(platformAudio.IsSpeakerOutputPreferred);
+
+ // Setting the bool rewrites the Speaker/Earpiece order inside the list.
+ platformAudio.IsSpeakerOutputPreferred = false;
+ CollectionAssert.AreEqual(
+ new[]
+ {
+ AudioOutputKind.Bluetooth,
+ AudioOutputKind.WiredHeadset,
+ AudioOutputKind.Earpiece,
+ AudioOutputKind.Speaker,
+ },
+ platformAudio.OutputPreference);
+ Assert.IsFalse(platformAudio.IsSpeakerOutputPreferred);
+
+ // Setting the list order flips the bool back — the list is the source of truth.
+ platformAudio.OutputPreference = new[]
+ {
+ AudioOutputKind.Speaker,
+ AudioOutputKind.Earpiece,
+ AudioOutputKind.Bluetooth,
+ };
+ Assert.IsTrue(platformAudio.IsSpeakerOutputPreferred);
+
+ // A missing kind is inserted next to the present one so the value round-trips.
+ platformAudio.OutputPreference = new[] { AudioOutputKind.Bluetooth, AudioOutputKind.Speaker };
+ platformAudio.IsSpeakerOutputPreferred = false;
+ CollectionAssert.AreEqual(
+ new[] { AudioOutputKind.Bluetooth, AudioOutputKind.Earpiece, AudioOutputKind.Speaker },
+ platformAudio.OutputPreference);
+ Assert.IsFalse(platformAudio.IsSpeakerOutputPreferred);
+
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator SelectOutput_BogusDevice_Throws()
+ {
+ using var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
+
+ var bogus = new AudioDevice { Index = 9999, Name = "not-a-device", Guid = "no-such-guid" };
+ Assert.Throws(() => platformAudio.SelectOutput(bogus));
+
+ // Clearing is always safe, whether or not an override exists.
+ Assert.DoesNotThrow(() => platformAudio.ClearOutputOverride());
+
+ yield break;
+ }
+
+ [UnityTest]
+ public IEnumerator DevicesChanged_SubscribeUnsubscribe_SafeAcrossDispose()
+ {
+ var platformAudio = PlatformAudioTestHelper.TryCreateOrIgnore();
+
+ Action, IReadOnlyList> handler = (playout, recording) => { };
+ platformAudio.DevicesChanged += handler;
+ platformAudio.Dispose();
+
+ Assert.DoesNotThrow(() => platformAudio.DevicesChanged -= handler);
+ Assert.DoesNotThrow(() => platformAudio.DevicesChanged += handler);
+ Assert.DoesNotThrow(() => platformAudio.Dispose());
+
+ yield break;
+ }
+
[UnityTest]
public IEnumerator StartThenStopRecording_DoesNotThrow()
{