diff --git a/Runtime/Plugins/iOS/LiveKitAudioSession.mm b/Runtime/Plugins/iOS/LiveKitAudioSession.mm index e3907cba..958fb3a7 100644 --- a/Runtime/Plugins/iOS/LiveKitAudioSession.mm +++ b/Runtime/Plugins/iOS/LiveKitAudioSession.mm @@ -17,6 +17,9 @@ #import #import +#include +#include + // This plugin coordinates the single shared AVAudioSession with WebRTC's iOS // Audio Device Module (ADM). WebRTC ships an RTCAudioSession proxy that, left in // its default "automatic" mode, reconfigures the category/route and *deactivates* @@ -30,11 +33,14 @@ // * We hold exactly one permanent activation (setActive:YES). Because // RTCAudioSession ref-counts activation, WebRTC's per-call setActive:YES/NO // only cycles the count and never actually deactivates the hardware session. -// * We set the category once (PlayAndRecord + VideoChat mode). VideoChat routes -// to the loudspeaker by default (while still honoring connected wired/Bluetooth -// headphones), so WebRTC re-applying its own config keeps output on the speaker -// instead of the earpiece. We deliberately do NOT force the speaker via -// overrideOutputAudioPort, which would override plugged-in headphones. +// * The category/mode/options are derived from a session STATE machine driven +// from C# (PlatformAudio knows the call's recording state) plus a +// speaker-vs-earpiece preference (see the table below). Every apply is also +// mirrored into WebRTC's RTCAudioSessionConfiguration snapshot so the ADM +// re-applies the same config on its own restarts. +// * The speaker preference is expressed via the session MODE only (VideoChat +// routes to the loudspeaker by default, VoiceChat to the receiver), never via +// overrideOutputAudioPort, so connected wired/Bluetooth devices always win. // * The VPIO voice-processing unit (hardware AEC/AGC/NS) is gated by // isAudioEnabled. It defaults to YES so call audio works out of the box (the // unit still only initializes once a call actually has an audio track, so @@ -46,8 +52,28 @@ // with no retry -- and Unity/FMOD restarts *its* audio around the same moment, // reconfiguring the shared session (observed with Unity 6). Whoever loses that // race stays broken, so we observe foreground/interruption-end ourselves and, -// after Unity's restart has settled, re-assert our config and cycle -// isAudioEnabled to force a clean rebuild of the audio unit. +// after Unity's restart has settled, re-assert the current state's config and +// cycle isAudioEnabled to force a clean rebuild of the audio unit. +// * Route changes (headset plug/unplug, Bluetooth connect, mode switches) are +// observed via AVAudioSessionRouteChangeNotification and forwarded to C# +// through a registered callback so the SDK can raise its DevicesChanged event. +// +// Session state table (state is set from C# via LiveKit_SetSessionState): +// +// state category mode options +// 0 idle PlayAndRecord Default BT | A2DP | MixWithOthers +// | DefaultToSpeaker* +// 1 playout-only PlayAndRecord Default same as idle +// 2 recording PlayAndRecord VideoChat (speaker) / BT | A2DP +// VoiceChat (earpiece) +// +// *DefaultToSpeaker only while the speaker is preferred. In the recording state +// the speaker preference is carried by the mode alone. Idle and playout-only +// share a config: PlayAndRecord stays because the ADM initializes its VPIO unit +// with input disabled for playout-only (InitPlayOrRecord(false)) but nothing +// guarantees VPIO under the Playback category; mode Default + MixWithOthers is +// the music-friendliest config the ADM demonstrably supports. The states stay +// distinct so the mapping can diverge without touching the C# driver. // // RTCAudioSession lives inside the statically-linked liblivekit_ffi; we reach it // dynamically via NSClassFromString + a protocol-typed id so this file never @@ -68,6 +94,23 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category error:(NSError**)outError; @end +/// Minimal subset of WebRTC's RTCAudioSessionConfiguration (the snapshot the ADM +/// re-applies on its own restarts), messaged dynamically like RTCAudioSession. +@protocol LiveKitRTCAudioSessionConfiguration +@property(nonatomic, strong) NSString* category; +@property(nonatomic, assign) AVAudioSessionCategoryOptions categoryOptions; +@property(nonatomic, strong) NSString* mode; +@end + +/// Session states, mirroring PlatformAudio's driver in C#. Do not renumber. +enum { + kLiveKitSessionStateIdle = 0, + kLiveKitSessionStatePlayoutOnly = 1, + kLiveKitSessionStateRecording = 2, +}; + +typedef void (*LiveKitRouteChangeCallback)(void); + // Tracks whether *we* currently hold the one app-owned activation, so we add and // release it exactly once regardless of how many times configure/restore run. static BOOL s_liveKitHoldsActivation = NO; @@ -91,10 +134,27 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category // on foreground) into one delayed pass. static BOOL s_recoveryPending = NO; -static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions = - AVAudioSessionCategoryOptionDefaultToSpeaker | +// The state machine inputs (see the table above). The defaults match what a fresh +// PlatformAudio pushes right after construction, so the config applied by +// configure is already the one the C# driver expects. +static int s_sessionState = kLiveKitSessionStatePlayoutOnly; +static BOOL s_speakerPreferred = YES; + +// Invoked (on the main queue) whenever the audio route changes, so the C# side +// can re-query the route and raise DevicesChanged. +static LiveKitRouteChangeCallback s_routeChangeCallback = NULL; + +// AllowBluetooth was renamed AllowBluetoothHFP in the iOS 26 SDK; same guard the +// WebRTC fork uses. The speaker preference never rides on these options. +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 +static const AVAudioSessionCategoryOptions kLiveKitBluetoothOptions = + AVAudioSessionCategoryOptionAllowBluetoothHFP | + AVAudioSessionCategoryOptionAllowBluetoothA2DP; +#else +static const AVAudioSessionCategoryOptions kLiveKitBluetoothOptions = AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionAllowBluetoothA2DP; +#endif /// Returns WebRTC's shared RTCAudioSession if it's present in the linked binary, /// or nil if the class can't be found (in which case callers use AVAudioSession). @@ -109,7 +169,110 @@ - (BOOL)setCategory:(AVAudioSessionCategory)category #pragma clang diagnostic pop } -/// Re-applies LiveKit's category/mode/options and reactivates the session, then +static NSString* LiveKit_DesiredMode() { + if (s_sessionState == kLiveKitSessionStateRecording) { + return s_speakerPreferred ? AVAudioSessionModeVideoChat + : AVAudioSessionModeVoiceChat; + } + return AVAudioSessionModeDefault; +} + +static AVAudioSessionCategoryOptions LiveKit_DesiredOptions() { + AVAudioSessionCategoryOptions options = kLiveKitBluetoothOptions; + if (s_sessionState != kLiveKitSessionStateRecording) { + options |= AVAudioSessionCategoryOptionMixWithOthers; + // Mode Default routes PlayAndRecord to the receiver; outside a call there + // is no mode that both prefers the speaker and leaves music processing + // alone, so here -- and only here -- the preference rides on an option. + if (s_speakerPreferred) { + options |= AVAudioSessionCategoryOptionDefaultToSpeaker; + } + } + return options; +} + +/// Mirrors our category/mode/options into WebRTC's RTCAudioSessionConfiguration +/// snapshot so the ADM re-applies the same config whenever it (re)configures the +/// session itself (audio unit init, interruption recovery). +static void LiveKit_MirrorWebRTCConfiguration(NSString* mode, + AVAudioSessionCategoryOptions options) { + Class cls = NSClassFromString(@"RTCAudioSessionConfiguration"); + if (!cls || ![cls respondsToSelector:@selector(webRTCConfiguration)] || + ![cls respondsToSelector:@selector(setWebRTCConfiguration:)]) { + return; + } +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + id config = + (id)[cls performSelector:@selector(webRTCConfiguration)]; + if (config == nil) { + return; + } + config.category = AVAudioSessionCategoryPlayAndRecord; + config.mode = mode; + config.categoryOptions = options; + [cls performSelector:@selector(setWebRTCConfiguration:) withObject:config]; +#pragma clang diagnostic pop +} + +/// Applies the config derived from (s_sessionState, s_speakerPreferred) to the +/// session and mirrors it into the WebRTC snapshot. Logs expected vs. actual so +/// device tests can see who won when something else reconfigures the session. +/// +/// rebuildAudioUnitOnModeChange: the ADM does not rebuild its VPIO unit on a +/// route change that keeps the hardware sample rate (HandleValidRouteChange -> +/// HandleSampleRateChange no-ops when the audio parameters are intact), so a +/// live mode switch leaves the unit calibrated for the previous route -- +/// device-observed as an attenuated loudspeaker after an earpiece -> speaker +/// toggle. Passing YES cycles isAudioEnabled after a mode change to force a +/// clean rebuild against the new route, at the cost of a brief audio gap. +/// Callers that handle the rebuild themselves (foreground recovery) or run +/// before the unit exists (configure) pass NO. +static void LiveKit_ApplySessionConfig(NSString* reason, BOOL rebuildAudioUnitOnModeChange) { + NSString* mode = LiveKit_DesiredMode(); + AVAudioSessionCategoryOptions options = LiveKit_DesiredOptions(); + BOOL modeChanged = ![[AVAudioSession sharedInstance].mode isEqualToString:mode]; + + id rtc = LiveKit_RTCSession(); + NSError* error = nil; + if (rtc != nil) { + [rtc lockForConfiguration]; + if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord + mode:mode + options:options + error:&error] || error) { + NSLog(@"LiveKit: failed to apply session config (%@): %@", + reason, error.localizedDescription); + } + [rtc unlockForConfiguration]; + } else { + AVAudioSession* session = [AVAudioSession sharedInstance]; + if (![session setCategory:AVAudioSessionCategoryPlayAndRecord + mode:mode + options:options + error:&error] || error) { + NSLog(@"LiveKit: failed to apply session config (%@): %@", + reason, error.localizedDescription); + } + } + + LiveKit_MirrorWebRTCConfiguration(mode, options); + + AVAudioSession* current = [AVAudioSession sharedInstance]; + NSLog(@"LiveKit: session config (%@): state=%d speakerPreferred=%d expected mode=%@ options=%lu" + " -> actual category=%@ mode=%@ options=%lu", + reason, s_sessionState, s_speakerPreferred, mode, (unsigned long)options, + current.category, current.mode, (unsigned long)current.categoryOptions); + + if (rebuildAudioUnitOnModeChange && modeChanged && s_audioDesired && rtc != nil) { + rtc.isAudioEnabled = NO; + rtc.isAudioEnabled = YES; + NSLog(@"LiveKit: cycled isAudioEnabled to rebuild the audio unit after mode change (%@)", + reason); + } +} + +/// Re-applies the current state's config and reactivates the session, then /// cycles isAudioEnabled to force WebRTC to rebuild its VPIO audio unit. Runs on /// a delay so it lands after Unity/FMOD's own foreground audio restart (which is /// itself delayed and can reconfigure the shared session underneath WebRTC's @@ -131,31 +294,13 @@ static void LiveKit_ScheduleSessionRecovery() { NSLog(@"LiveKit: foreground recovery; session before re-assert: category=%@ mode=%@ options=%lu", session.category, session.mode, (unsigned long)session.categoryOptions); - id rtc = LiveKit_RTCSession(); - NSError* error = nil; - if (rtc != nil) { - [rtc lockForConfiguration]; - if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); - } - [rtc unlockForConfiguration]; - } else { - if (![session setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: recovery failed to re-set category: %@", error.localizedDescription); - } - } + LiveKit_ApplySessionConfig(@"foreground recovery", NO); // Reactivate directly on AVAudioSession: the OS deactivated the hardware // session during the interruption, but RTCAudioSession's activation // ref-count still includes our held activation, so reactivating through // the proxy would double-count it. - error = nil; + NSError* error = nil; if (![session setActive:YES error:&error] || error) { NSLog(@"LiveKit: recovery failed to reactivate session: %@", error.localizedDescription); } @@ -164,6 +309,7 @@ static void LiveKit_ScheduleSessionRecovery() { // session (WebRTC's own foreground restart may have failed, or been undone // by Unity's). Harmless when no call audio is active: with playout and // recording uninitialized WebRTC ignores the change. + id rtc = LiveKit_RTCSession(); if (rtc != nil && s_audioDesired) { rtc.isAudioEnabled = NO; rtc.isAudioEnabled = YES; @@ -174,9 +320,9 @@ static void LiveKit_ScheduleSessionRecovery() { }); } -/// Registers app-lifetime observers that trigger session recovery when the app -/// returns to the foreground or an audio interruption ends. Registered once on -/// first configure; the handlers no-op while LiveKit is not configured. +/// Registers app-lifetime observers for foreground/interruption recovery and for +/// route-change forwarding. Registered once on first configure; the handlers +/// no-op while LiveKit is not configured. static void LiveKit_RegisterLifecycleObserversIfNeeded() { static BOOL s_observersRegistered = NO; if (s_observersRegistered) { @@ -200,6 +346,27 @@ static void LiveKit_RegisterLifecycleObserversIfNeeded() { LiveKit_ScheduleSessionRecovery(); } }]; + [center addObserverForName:AVAudioSessionRouteChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + if (!s_liveKitConfigured) { + return; + } + NSNumber* reason = note.userInfo[AVAudioSessionRouteChangeReasonKey]; + NSMutableArray* outputs = [NSMutableArray array]; + for (AVAudioSessionPortDescription* port in + [AVAudioSession sharedInstance].currentRoute.outputs) { + [outputs addObject:[NSString stringWithFormat:@"%@ (%@)", port.portName, port.portType]]; + } + NSLog(@"LiveKit: route changed (reason=%lu) outputs=%@", + (unsigned long)reason.unsignedIntegerValue, + [outputs componentsJoinedByString:@", "]); + LiveKitRouteChangeCallback callback = s_routeChangeCallback; + if (callback != NULL) { + callback(); + } + }]; } /// Captures the current audio session category/mode/options exactly once, before @@ -219,15 +386,31 @@ static void LiveKit_CacheSessionStateIfNeeded() { s_cachedCategory, s_cachedMode, (unsigned long)s_cachedCategoryOptions); } +/// Maps an AVAudioSessionPort type to the C# AudioOutputKind numbering +/// (Unknown=0, Earpiece=1, Speaker=2, WiredHeadset=3, Bluetooth=4, Usb=5, +/// HearingAid=6). Do not renumber. AVAudioSession has no dedicated hearing-aid +/// port type, so 6 is never produced here; AirPlay/HDMI/CarAudio and other +/// unroutable-by-us ports map to Unknown. +static int LiveKit_OutputKindForPortType(NSString* portType) { + if ([portType isEqualToString:AVAudioSessionPortBuiltInReceiver]) return 1; + if ([portType isEqualToString:AVAudioSessionPortBuiltInSpeaker]) return 2; + if ([portType isEqualToString:AVAudioSessionPortHeadphones]) return 3; + if ([portType isEqualToString:AVAudioSessionPortBluetoothA2DP] || + [portType isEqualToString:AVAudioSessionPortBluetoothHFP] || + [portType isEqualToString:AVAudioSessionPortBluetoothLE]) return 4; + if ([portType isEqualToString:AVAudioSessionPortUSBAudio]) return 5; + return 0; +} + extern "C" { /// Configures the iOS audio session for VoIP/WebRTC use and takes app ownership /// of the shared AVAudioSession. /// -/// This sets AVAudioSessionCategoryPlayAndRecord with VideoChat mode (which routes -/// to the loudspeaker by default and enables the VPIO Voice Processing IO unit for -/// hardware AEC/AGC/NS), puts RTCAudioSession into manual mode, and holds a single -/// permanent activation so WebRTC never deactivates the session on its own. +/// This applies the config for the current session state (playout-only for a +/// fresh PlatformAudio; see the state table at the top of this file), puts +/// RTCAudioSession into manual mode, and holds a single permanent activation so +/// WebRTC never deactivates the session on its own. /// /// Call this before creating PlatformAudio. Call audio is enabled by default, so /// no further call is required for it to work; use LiveKit_SetAudioEnabled(false) @@ -242,53 +425,42 @@ void LiveKit_ConfigureAudioSessionForVoIP() { id rtc = LiveKit_RTCSession(); + // Manual mode: WebRTC won't activate/deactivate the session on its own, and + // won't initialize the VPIO unit until we grant permission via isAudioEnabled + // (set below). This is what lets us own activation and gate the unit. Set + // before the first apply so the ADM never races the initial configuration. + if (rtc != nil) { + rtc.useManualAudio = YES; + } + + LiveKit_ApplySessionConfig(@"configure", NO); + if (rtc == nil) { - // RTCAudioSession unavailable: configure AVAudioSession directly (legacy). + // RTCAudioSession unavailable: activate AVAudioSession directly (legacy). AVAudioSession* session = [AVAudioSession sharedInstance]; NSError* error = nil; - if (![session setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: Failed to configure audio session: %@", error.localizedDescription); - return; - } if (![session setActive:YES error:&error] || error) { NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); return; } - NSLog(@"LiveKit: Audio session configured (AVAudioSession fallback, VideoChat)"); + NSLog(@"LiveKit: Audio session configured (AVAudioSession fallback)"); return; } - // Manual mode: WebRTC won't activate/deactivate the session on its own, and - // won't initialize the VPIO unit until we grant permission via isAudioEnabled - // (set below). This is what lets us own activation and gate the unit. - rtc.useManualAudio = YES; - - [rtc lockForConfiguration]; - NSError* error = nil; - if (![rtc setCategory:AVAudioSessionCategoryPlayAndRecord - mode:AVAudioSessionModeVideoChat - options:kLiveKitCategoryOptions - error:&error] || error) { - NSLog(@"LiveKit: Failed to set audio category: %@", error.localizedDescription); - } - // Hold exactly one app-owned activation. RTCAudioSession ref-counts activation, // so WebRTC's balanced setActive:YES/NO during a call never drops the real // session below active while we hold this. if (!s_liveKitHoldsActivation) { - error = nil; + [rtc lockForConfiguration]; + NSError* error = nil; if ([rtc setActive:YES error:&error] && !error) { s_liveKitHoldsActivation = YES; } else { NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription); } + [rtc unlockForConfiguration]; } - [rtc unlockForConfiguration]; - // Grant WebRTC permission to initialize its audio unit by default so call audio // works without an explicit LiveKit_SetAudioEnabled(true). The unit is only // actually created once a call has an audio track, so pre-call audio is @@ -296,7 +468,7 @@ void LiveKit_ConfigureAudioSessionForVoIP() { // LiveKit_SetAudioEnabled(false). rtc.isAudioEnabled = YES; - NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VideoChat, manual mode, activationCount=%d)", + NSLog(@"LiveKit: Audio session configured for VoIP (manual mode, activationCount=%d)", rtc.activationCount); } @@ -317,6 +489,65 @@ void LiveKit_SetAudioEnabled(bool enabled) { NSLog(@"LiveKit: isAudioEnabled=%@ (activationCount=%d)", enabled ? @"YES" : @"NO", rtc.activationCount); } +/// Sets whether the loudspeaker is preferred over the earpiece for the built-in +/// outputs and live-applies the resulting config (see the state table). External +/// devices (wired, Bluetooth) always take priority over both; this only decides +/// where audio goes when no external device is connected. +void LiveKit_SetSpeakerPreferred(bool preferred) { + BOOL value = preferred ? YES : NO; + if (s_speakerPreferred == value) { + return; + } + s_speakerPreferred = value; + if (s_liveKitConfigured) { + LiveKit_ApplySessionConfig(@"speaker preference", YES); + } +} + +/// Sets the session state (0 idle, 1 playout-only, 2 recording; see the state +/// table) and live-applies the resulting config. Driven from C#: PlatformAudio +/// knows whether recording is active and whether call audio is wanted. +void LiveKit_SetSessionState(int state) { + if (state < kLiveKitSessionStateIdle || state > kLiveKitSessionStateRecording) { + NSLog(@"LiveKit: ignoring unknown session state %d", state); + return; + } + if (s_sessionState == state) { + return; + } + s_sessionState = state; + if (s_liveKitConfigured) { + LiveKit_ApplySessionConfig(@"session state", YES); + } +} + +/// Registers (or clears, with NULL) the callback invoked on the main queue +/// whenever the audio route changes. The callback carries no payload; the C# +/// side re-queries LiveKit_GetCurrentOutputRoutes. +void LiveKit_SetRouteChangeCallback(LiveKitRouteChangeCallback callback) { + s_routeChangeCallback = callback; +} + +/// Returns the current output route as newline-separated "kind\tname\tuid" +/// entries (kind per LiveKit_OutputKindForPortType). The caller must release the +/// returned buffer with LiveKit_FreeRouteString. +char* LiveKit_GetCurrentOutputRoutes() { + NSMutableString* result = [NSMutableString string]; + for (AVAudioSessionPortDescription* port in + [AVAudioSession sharedInstance].currentRoute.outputs) { + [result appendFormat:@"%d\t%@\t%@\n", + LiveKit_OutputKindForPortType(port.portType), + port.portName ?: @"", + port.UID ?: @""]; + } + return strdup(result.UTF8String); +} + +/// Frees a buffer returned by LiveKit_GetCurrentOutputRoutes. +void LiveKit_FreeRouteString(char* str) { + free(str); +} + /// Restores the audio session Unity had before LiveKit touched it (or the ambient /// category if LiveKit never configured it), relinquishes the app-owned activation /// and manual mode, and reactivates the session so Unity audio output resumes. @@ -325,6 +556,10 @@ void LiveKit_RestoreDefaultAudioSession() { // Stand down the foreground-recovery observers before touching the session. s_liveKitConfigured = NO; s_audioDesired = NO; + // Reset the state machine to the defaults a fresh PlatformAudio expects, so a + // later reconfigure starts from the same config it will be driven to. + s_sessionState = kLiveKitSessionStatePlayoutOnly; + s_speakerPreferred = YES; id rtc = LiveKit_RTCSession(); diff --git a/Runtime/Scripts/Audio/IosRouteController.cs b/Runtime/Scripts/Audio/IosRouteController.cs new file mode 100644 index 00000000..7dc5ef08 --- /dev/null +++ b/Runtime/Scripts/Audio/IosRouteController.cs @@ -0,0 +1,216 @@ +#if UNITY_IOS && !UNITY_EDITOR +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using LiveKit.Internal; + +namespace LiveKit +{ + /// + /// iOS routing backend over the LiveKitAudioSession.mm plugin. The OS owns output + /// route selection on iOS, so this backend does not pick devices: it reduces + /// to the speaker-vs-earpiece relative + /// order (applied as the audio session mode by the plugin; external devices always + /// take priority over both built-ins), reports the session's current output route + /// as the playout device list, and raises from the + /// plugin's route-change observation. throws: apps that + /// want explicit device picking should present the system route picker + /// (AVRoutePickerView). + /// + /// All plugin P/Invoke for route observation stays inside this class; the session + /// state machine itself is driven by (which knows the + /// recording state) through . + /// + internal sealed class IosRouteController : IRouteController + { + private delegate void RouteChangeDelegate(); + + [DllImport("__Internal")] + private static extern void LiveKit_SetRouteChangeCallback(RouteChangeDelegate callback); + + [DllImport("__Internal")] + private static extern void LiveKit_SetSpeakerPreferred([MarshalAs(UnmanagedType.I1)] bool preferred); + + [DllImport("__Internal")] + private static extern IntPtr LiveKit_GetCurrentOutputRoutes(); + + [DllImport("__Internal")] + private static extern void LiveKit_FreeRouteString(IntPtr routes); + + // The native callback slot is registered once for the app lifetime (matching + // the plugin's app-lifetime notification observers) and fans out to the live + // controllers; keeping the delegate in a static field pins it for the native + // side. Instances add and remove themselves under StaticGate. + private static readonly object StaticGate = new object(); + private static readonly List LiveControllers = new List(); + private static readonly RouteChangeDelegate NativeRouteChanged = OnNativeRouteChanged; + private static bool _callbackRegistered; + + private readonly object _gate = new object(); + // The FFI recording list (a single placeholder for the OS default input), + // captured once: route changes never affect it and re-querying the FFI from + // the route callback would be wasted work. + private readonly List _recordingSnapshot; + private string _lastSignature; + private bool _disposed; + + public event Action, IReadOnlyList> DevicesChanged; + + internal IosRouteController(PlatformAudio owner, IReadOnlyList initialPreference) + { + _recordingSnapshot = owner.GetDevicesViaFfi().Recording; + + ApplyOutputPreference(initialPreference); + _lastSignature = Signature(QueryCurrentOutputs()); + + lock (StaticGate) + { + LiveControllers.Add(this); + if (!_callbackRegistered) + { + LiveKit_SetRouteChangeCallback(NativeRouteChanged); + _callbackRegistered = true; + } + } + } + + public (List Recording, List Playout) GetDevices() + { + return (new List(_recordingSnapshot), QueryCurrentOutputs()); + } + + public void ApplyOutputPreference(IReadOnlyList ranked) + { + // Reduce the ranked list per the PAR-019 precedence rule: the only part of + // the ranking iOS can express is whether Speaker outranks Earpiece. + var speaker = -1; + var earpiece = -1; + for (var i = 0; i < ranked.Count; i++) + { + if (ranked[i] == AudioOutputKind.Speaker) speaker = i; + else if (ranked[i] == AudioOutputKind.Earpiece) earpiece = i; + } + var speakerPreferred = speaker >= 0 && (earpiece < 0 || speaker < earpiece); + LiveKit_SetSpeakerPreferred(speakerPreferred); + } + + public void SelectOutput(AudioDevice device) + { + throw new NotSupportedException( + "SelectOutput is not supported on iOS: the OS owns output route selection. " + + "Present the system route picker (AVRoutePickerView) instead, or use " + + "OutputPreference / IsSpeakerOutputPreferred for the built-in outputs."); + } + + public void ClearOutputOverride() + { + // No override can exist on iOS: SelectOutput throws. + } + + public void Dispose() + { + lock (StaticGate) + { + LiveControllers.Remove(this); + } + lock (_gate) + { + _disposed = true; + } + } + + /// + /// Native route-change entry point, invoked by the plugin on the iOS main + /// queue (not the Unity main thread; marshals the + /// public event). + /// + [AOT.MonoPInvokeCallback(typeof(RouteChangeDelegate))] + private static void OnNativeRouteChanged() + { + IosRouteController[] controllers; + lock (StaticGate) + { + controllers = LiveControllers.ToArray(); + } + foreach (var controller in controllers) + controller.HandleRouteChanged(); + } + + private void HandleRouteChanged() + { + List playout; + lock (_gate) + { + if (_disposed) return; + + playout = QueryCurrentOutputs(); + var signature = Signature(playout); + if (signature == _lastSignature) return; + _lastSignature = signature; + } + + DevicesChanged?.Invoke(playout, new List(_recordingSnapshot)); + } + + /// + /// The current output route reported by the audio session. On iOS this is the + /// active route (usually one device), not an enumeration of every reachable + /// device — AVAudioSession exposes no such list for outputs. + /// + private static List QueryCurrentOutputs() + { + var devices = new List(); + + var routesPtr = LiveKit_GetCurrentOutputRoutes(); + if (routesPtr == IntPtr.Zero) return devices; + + string routes; + try + { + routes = Marshal.PtrToStringUTF8(routesPtr); + } + finally + { + LiveKit_FreeRouteString(routesPtr); + } + if (string.IsNullOrEmpty(routes)) return devices; + + foreach (var line in routes.Split('\n')) + { + if (line.Length == 0) continue; + var fields = line.Split('\t'); + if (fields.Length != 3) + { + Utils.Warning($"IosRouteController: malformed route entry '{line}'"); + continue; + } + + var kind = int.TryParse(fields[0], out var rawKind) + && Enum.IsDefined(typeof(AudioOutputKind), rawKind) + ? (AudioOutputKind)rawKind + : AudioOutputKind.Unknown; + devices.Add(new AudioDevice + { + Index = (uint)devices.Count, + Name = fields[1], + Guid = fields[2], + Kind = kind, + // Everything in the current route is live output by definition. + IsSelected = true, + }); + } + + return devices; + } + + private static string Signature(List playout) + { + var builder = new StringBuilder(); + foreach (var device in playout) + builder.Append(device.Guid).Append('\u001f').Append((int)device.Kind).Append('\u001e'); + return builder.ToString(); + } + } +} +#endif diff --git a/Runtime/Scripts/Audio/IosRouteController.cs.meta b/Runtime/Scripts/Audio/IosRouteController.cs.meta new file mode 100644 index 00000000..069ce527 --- /dev/null +++ b/Runtime/Scripts/Audio/IosRouteController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2b7a83f0ed8eb4ce1bbd824fdf80c424 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Scripts/Audio/PlatformAudio.cs b/Runtime/Scripts/Audio/PlatformAudio.cs index cfe13639..7a13245e 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; @@ -42,9 +43,43 @@ internal static class IOSAudioSessionHelper /// [DllImport("__Internal")] internal static extern void LiveKit_SetAudioEnabled([MarshalAs(UnmanagedType.I1)] bool enabled); + + /// + /// Sets the audio session state (0 idle, 1 playout-only, 2 recording) so the + /// plugin can apply the matching category/mode/options (see the state table in + /// LiveKitAudioSession.mm). Driven by PlatformAudio, which knows whether + /// recording is active and whether call audio is wanted. + /// + [DllImport("__Internal")] + internal static extern void LiveKit_SetSessionState(int state); } #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). /// @@ -60,6 +95,19 @@ public struct AudioDevice /// over index for device selection. /// public string Guid; + /// + /// The kind of output this device represents. Reported on iOS for playout devices + /// (classified from the audio session's current route); where the platform does not report a type — + /// recording devices, desktop, and Android, which has no routing backend yet. + /// + public AudioOutputKind Kind; + /// + /// Whether this device is the active output route. Reported on iOS for playout + /// devices; false where no routing backend reports selection state — recording + /// devices, desktop, and Android, which has no routing backend yet. + /// + public bool IsSelected; } /// @@ -84,13 +132,42 @@ 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; #if UNITY_IOS && !UNITY_EDITOR // Tracks live PlatformAudio instances so the iOS audio session is restored // only when the last one is disposed (aligned with the native ADM ref-count). private static int _instanceCount; + + // Inputs of the iOS session-state machine (see the state table in + // LiveKitAudioSession.mm). PlatformAudio is the driver because it is the one + // that knows both: whether recording is active (its own StartRecording/ + // StopRecording calls) and whether call audio is wanted (SetSessionAudioEnabled). + private const int IosSessionStateIdle = 0; + private const int IosSessionStatePlayoutOnly = 1; + private const int IosSessionStateRecording = 2; + private bool _iosRecordingActive; + private bool _iosSessionAudioEnabled = true; + + private void UpdateIosSessionState() + { + var state = !_iosSessionAudioEnabled ? IosSessionStateIdle + : _iosRecordingActive ? IosSessionStateRecording + : IosSessionStatePlayoutOnly; + IOSAudioSessionHelper.LiveKit_SetSessionState(state); + } #endif + private static readonly AudioOutputKind[] DefaultOutputPreference = + { + AudioOutputKind.Bluetooth, + AudioOutputKind.WiredHeadset, + AudioOutputKind.Speaker, + AudioOutputKind.Earpiece, + }; + /// /// Number of available recording (microphone) devices. /// @@ -107,9 +184,12 @@ public sealed class PlatformAudio : IDisposable /// This must be called before creating any PlatformAudioSource or connecting /// to a room if you want automatic speaker playout for remote audio. /// - /// On iOS, this automatically configures the audio session for VoIP mode - /// (PlayAndRecord category with VideoChat mode) to enable hardware echo - /// cancellation and microphone input. + /// On iOS, this automatically configures the audio session for VoIP use and + /// takes app ownership of it. The session's mode follows the call state: a + /// voice/video-chat mode (enabling hardware echo cancellation) while recording + /// is active, and a music-friendly default mode otherwise (see + /// / / + /// ). /// /// /// Thrown if the platform ADM could not be initialized (e.g., no audio devices, @@ -134,6 +214,17 @@ public PlatformAudio() Handle = FfiHandle.FromOwnedHandle(platformAudio.Handle); _info = platformAudio.Info; + _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(); +#endif + Utils.Debug($"PlatformAudio created: {RecordingDeviceCount} recording devices, {PlayoutDeviceCount} playout devices"); #if UNITY_IOS && !UNITY_EDITOR @@ -143,6 +234,17 @@ public PlatformAudio() #endif } + private IRouteController CreateRouteController() + { +#if UNITY_ANDROID && !UNITY_EDITOR + return new UnsupportedRouteController(this, "Android"); +#elif UNITY_IOS && !UNITY_EDITOR + return new IosRouteController(this, _outputPreference); +#else + return new DesktopRouteController(this); +#endif + } + /// /// Gets the lists of available recording and playout devices. /// @@ -150,24 +252,38 @@ public PlatformAudio() /// - Desktop (Windows/macOS/Linux): returns the full list of microphones and /// speakers reported by the OS. Devices can be selected with /// / . - /// - iOS and Android: returns a single placeholder entry at index 0 for each - /// list, representing the system's currently selected default input/output. - /// The OS owns audio routing on these platforms (AVAudioSession on iOS, - /// AudioManager on Android), so individual devices are not enumerated and - /// selecting one is a no-op (see / + /// - iOS: the playout list is the audio session's current output route (usually + /// one device, with and + /// set) — iOS does not enumerate every + /// reachable output device. The recording list is a single placeholder entry + /// for the OS default input. + /// - Android: returns a single placeholder entry at index 0 for each list, + /// representing the system's currently selected default input/output. The OS + /// owns audio routing (AudioManager), so individual devices are not enumerated + /// and selecting one is a no-op (see / /// ). /// /// /// A tuple containing: /// - Recording: List of available microphones (on iOS/Android, a single /// placeholder for the OS default input) - /// - Playout: List of available speakers/headphones (on iOS/Android, a single - /// placeholder for the OS default output) + /// - Playout: List of available speakers/headphones (on iOS, the current output + /// route; on Android, a single placeholder for the OS default output) /// /// /// 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(); @@ -201,6 +317,208 @@ 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; it is applied through the audio session mode and takes effect + /// immediately, including mid-call. On desktop, output is selected per device + /// ( / ) and the + /// ranking has no routing effect. The Android routing backend is not implemented + /// yet in this version: the value is stored and round-trips, but has no routing + /// effect there. + /// + /// 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. 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. On desktop, output is + /// selected per device ( / + /// ) and the ranking has no routing effect. + /// The Android routing backend is not implemented yet in this version: the value + /// is stored and round-trips, but has no routing effect there. + /// + 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 iOS the OS owns output route + /// selection and this method throws — present + /// the system route picker (AVRoutePickerView) instead, or use + /// / for the + /// built-in outputs. On Android the routing backend is not implemented yet in + /// this version and this method also throws. + /// + /// A playout device from . + /// + /// Thrown if the device does not match any current playout device. + /// + /// + /// Thrown on iOS (the OS owns route selection) and on Android (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 iOS and Android no + /// override can exist ( 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. + /// + /// On iOS this fires when the audio session's output route changes (headset + /// plugged/unplugged, Bluetooth connected, speaker/earpiece switches); the playout + /// list is the new route. Desktop hot-plug events and the Android backend are not + /// implemented yet in this version, so the event is never raised there. 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. /// @@ -305,6 +623,9 @@ public void SetPlayoutDevice(string deviceId) /// Recording is started automatically when PlatformAudio is created. /// Use this to resume recording after calling StopRecording. /// This turns on the system's recording privacy indicator (e.g., on macOS/iOS). + /// On iOS this also switches the audio session to its recording state + /// (voice/video-chat mode per , enabling + /// hardware echo cancellation). /// /// /// Thrown if the operation failed. @@ -344,6 +665,11 @@ public IEnumerator StartRecording() if (res.StartRecording.HasError && !string.IsNullOrEmpty(res.StartRecording.Error)) throw new InvalidOperationException($"Failed to start recording: {res.StartRecording.Error}"); +#if UNITY_IOS && !UNITY_EDITOR + _iosRecordingActive = true; + UpdateIosSessionState(); +#endif + Utils.Debug("PlatformAudio: started recording"); // Ensures this method is always a valid iterator even when the PLATFORM_ANDROID @@ -358,6 +684,8 @@ public IEnumerator StartRecording() /// Use this to temporarily stop recording without disposing PlatformAudio. /// This turns off the system's recording privacy indicator (e.g., on macOS/iOS). /// Call StartRecording to resume recording. + /// On iOS this also switches the audio session back to its playout-only state + /// (music-friendly default mode). /// /// /// Thrown if the operation failed. @@ -373,6 +701,11 @@ public void StopRecording() if (res.StopRecording.HasError && !string.IsNullOrEmpty(res.StopRecording.Error)) throw new InvalidOperationException($"Failed to stop recording: {res.StopRecording.Error}"); +#if UNITY_IOS && !UNITY_EDITOR + _iosRecordingActive = false; + UpdateIosSessionState(); +#endif + Utils.Debug("PlatformAudio: stopped recording"); } @@ -383,7 +716,8 @@ public void StopRecording() /// of the shared AVAudioSession. It is enabled by default when PlatformAudio is /// created, so this only needs to be called to false when leaving a room /// (and back to true when rejoining). Disabling stops the microphone/ - /// remote audio path and the hardware voice processing, but keeps the audio + /// remote audio path and the hardware voice processing, and drops the session + /// to its idle state (music-friendly default mode), but keeps the audio /// session active so other Unity audio (e.g. background music) is not /// interrupted — which is why Unity audio survives a hang-up. /// @@ -394,6 +728,8 @@ public void SetSessionAudioEnabled(bool enabled) { #if UNITY_IOS && !UNITY_EDITOR IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled); + _iosSessionAudioEnabled = enabled; + UpdateIosSessionState(); #endif Utils.Debug($"PlatformAudio: session audio enabled={enabled}"); } @@ -407,6 +743,8 @@ public void SetSessionAudioEnabled(bool enabled) public void Dispose() { if (_disposed) return; + _routeController.DevicesChanged -= OnRouteControllerDevicesChanged; + _routeController.Dispose(); Handle.Dispose(); #if UNITY_IOS && !UNITY_EDITOR diff --git a/Runtime/Scripts/Audio/RouteController.cs b/Runtime/Scripts/Audio/RouteController.cs new file mode 100644 index 00000000..edb8dc2a --- /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). 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() {