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
268 changes: 268 additions & 0 deletions Runtime/Scripts/Audio/PlatformAudio.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,6 +35,31 @@ internal static class IOSAudioSessionHelper
}
#endif

/// <summary>
/// The kind of audio output device, used for ranked routing policies on mobile
/// platforms (see <see cref="PlatformAudio.OutputPreference"/>).
///
/// 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.
/// </summary>
public enum AudioOutputKind
{
/// <summary>The platform did not report a device type.</summary>
Unknown = 0,
/// <summary>The phone's built-in earpiece (receiver).</summary>
Earpiece = 1,
/// <summary>The built-in loudspeaker.</summary>
Speaker = 2,
/// <summary>A wired headset or headphones.</summary>
WiredHeadset = 3,
/// <summary>A Bluetooth audio device.</summary>
Bluetooth = 4,
/// <summary>A USB audio device.</summary>
Usb = 5,
/// <summary>A hearing aid.</summary>
HearingAid = 6,
}

/// <summary>
/// Information about an audio device (microphone or speaker).
/// </summary>
Expand All @@ -49,6 +75,17 @@ public struct AudioDevice
/// over index for device selection.
/// </summary>
public string Guid;
/// <summary>
/// The kind of output this device represents. <see cref="AudioOutputKind.Unknown"/>
/// where the platform does not report a type — currently all devices: no routing
/// backend classifies devices yet.
/// </summary>
public AudioOutputKind Kind;
/// <summary>
/// Whether this device is the active output route. Only meaningful once a platform
/// routing backend reports selection state — currently always false.
/// </summary>
public bool IsSelected;
}

/// <summary>
Expand All @@ -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<AudioOutputKind> _outputPreference = new List<AudioOutputKind>(DefaultOutputPreference);
private bool _disposed = false;

private static readonly AudioOutputKind[] DefaultOutputPreference =
{
AudioOutputKind.Bluetooth,
AudioOutputKind.WiredHeadset,
AudioOutputKind.Speaker,
AudioOutputKind.Earpiece,
};

/// <summary>
/// Number of available recording (microphone) devices.
/// </summary>
Expand Down Expand Up @@ -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
}

/// <summary>
/// Gets the lists of available recording and playout devices.
///
Expand All @@ -146,6 +209,16 @@ public PlatformAudio()
/// Thrown if device enumeration failed.
/// </exception>
public (List<AudioDevice> Recording, List<AudioDevice> Playout) GetDevices()
{
return _routeController.GetDevices();
}

/// <summary>
/// Device enumeration through the FFI, shared by the route controllers.
/// <see cref="AudioDevice.Kind"/> and <see cref="AudioDevice.IsSelected"/> are not
/// reported by the FFI and stay at their defaults (Unknown / false).
/// </summary>
internal (List<AudioDevice> Recording, List<AudioDevice> Playout) GetDevicesViaFfi()
{
using var request = FFIBridge.Instance.NewRequest<GetAudioDevicesRequest>();
request.request.PlatformAudioHandle = (ulong)Handle.DangerousGetHandle();
Expand Down Expand Up @@ -179,6 +252,199 @@ public PlatformAudio()
return (recording, playout);
}

/// <summary>
/// Ranked automatic output routing policy, most preferred first. When no explicit
/// output override is active (<see cref="SelectOutput"/>), the platform routes to
/// the highest-ranked kind that has a connected device.
///
/// Default: Bluetooth > WiredHeadset > Speaker > Earpiece.
///
/// Precedence with <see cref="IsSpeakerOutputPreferred"/>: this list is the single
/// source of truth; the bool is convenience sugar that only rewrites the relative
/// order of <see cref="AudioOutputKind.Speaker"/> and
/// <see cref="AudioOutputKind.Earpiece"/> 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.
/// <see cref="IsSpeakerOutputPreferred"/> — is the only part of the ranking with an
/// effect. On Android the full ranking applies. On desktop, output is selected
/// per device (<see cref="SelectOutput"/> / <see cref="SetPlayoutDevice(string)"/>)
/// 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.
/// </summary>
/// <exception cref="ArgumentNullException">Thrown if set to null.</exception>
/// <exception cref="ArgumentException">
/// Thrown if the list contains <see cref="AudioOutputKind.Unknown"/> or duplicates.
/// </exception>
public IReadOnlyList<AudioOutputKind> OutputPreference
{
get => _outputPreference.AsReadOnly();
set
{
if (value == null)
throw new ArgumentNullException(nameof(value));

var ranked = new List<AudioOutputKind>(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());
}
}

/// <summary>
/// Whether the loudspeaker is preferred over the earpiece for automatic routing.
///
/// Precedence with <see cref="OutputPreference"/>: the list is the single source of
/// truth; this bool is convenience sugar that only rewrites the relative order of
/// <see cref="AudioOutputKind.Speaker"/> and <see cref="AudioOutputKind.Earpiece"/>
/// inside <see cref="OutputPreference"/>, 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 (<see cref="SelectOutput"/> / <see cref="SetPlayoutDevice(string)"/>)
/// 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.
/// </summary>
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<AudioOutputKind>(_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());
}
}

/// <summary>
/// Routes audio output to the given device as a sticky override of the automatic
/// <see cref="OutputPreference"/> policy: the route stays on the device until
/// <see cref="ClearOutputOverride"/> is called. The device is matched against the
/// current <see cref="GetDevices"/> playout list by <see cref="AudioDevice.Guid"/>
/// when set, otherwise by index and name.
///
/// Platform notes: on desktop this selects the device like
/// <see cref="SetPlayoutDevice(string)"/>. On Android and iOS the routing backends
/// are not implemented yet in this version and this method throws
/// <see cref="NotSupportedException"/>.
/// </summary>
/// <param name="device">A playout device from <see cref="GetDevices"/>.</param>
/// <exception cref="ArgumentException">
/// Thrown if the device does not match any current playout device.
/// </exception>
/// <exception cref="NotSupportedException">
/// Thrown on Android and iOS, where no routing backend exists yet.
/// </exception>
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));
}

/// <summary>
/// Clears the sticky override set by <see cref="SelectOutput"/> so the automatic
/// <see cref="OutputPreference"/> 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 (<see cref="SelectOutput"/> throws), so this is a no-op
/// there as well.
/// </summary>
public void ClearOutputOverride()
{
_routeController.ClearOutputOverride();
}

/// <summary>
/// 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 <see cref="Dispose"/>.
/// </summary>
public event Action<IReadOnlyList<AudioDevice>, IReadOnlyList<AudioDevice>> DevicesChanged;

private void OnRouteControllerDevicesChanged(
IReadOnlyList<AudioDevice> playout, IReadOnlyList<AudioDevice> recording)
{
if (_disposed) return;

if (_syncContext != null && _syncContext != SynchronizationContext.Current)
{
_syncContext.Post(_ =>
{
if (!_disposed)
DevicesChanged?.Invoke(playout, recording);
}, null);
return;
}

DevicesChanged?.Invoke(playout, recording);
}

/// <summary>
/// Sets the recording device (microphone) by index.
///
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading