From e5cecc62873671f0464a561e57de833e71de7831 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:43:13 +0200 Subject: [PATCH] Add public audio output routing API surface (PAR-019) Define the phase-A routing API on PlatformAudio, purely in C# and shaped identically to the planned FFI-backed implementation so app code written against it survives the plumbing swap: - AudioOutputKind enum (values mirror the planned FFI proto enum 1:1) - AudioDevice.Kind / AudioDevice.IsSelected - OutputPreference ranked policy (default BT > wired > speaker > earpiece) - IsSpeakerOutputPreferred as documented sugar over the list order - SelectOutput / ClearOutputOverride sticky override - DevicesChanged event (playout, recording) on the Unity main thread - internal IRouteController seam with desktop (FFI enumeration/GUID selection) and unsupported-mobile implementations; Android/iOS backends plug into the seam in follow-up work Co-Authored-By: Claude Fable 5 --- Runtime/Scripts/Audio/PlatformAudio.cs | 268 ++++++++++++++++++ Runtime/Scripts/Audio/RouteController.cs | 137 +++++++++ Runtime/Scripts/Audio/RouteController.cs.meta | 11 + Tests/PlayMode/PlatformAudioTests.cs | 120 ++++++++ 4 files changed, 536 insertions(+) create mode 100644 Runtime/Scripts/Audio/RouteController.cs create mode 100644 Runtime/Scripts/Audio/RouteController.cs.meta 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() {