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
191 changes: 161 additions & 30 deletions Runtime/Plugins/iOS/LiveKitAudioSession.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,182 @@

#import <AVFoundation/AVFoundation.h>

// 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*
// the session whenever a call's playout/recording starts and stops. That fights
// with Unity/FMOD: on join the app's other audio (e.g. ambient music) is rerouted
// to the earpiece and attenuated, and on hang-up the session is deactivated out
// from under Unity so its audio dies.
//
// To make playout stable and keep Unity audio alive across call state, we put
// RTCAudioSession into MANUAL mode and have the app own the session:
// * 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 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
// pre-call audio is unaffected). Callers can toggle it via
// LiveKit_SetAudioEnabled -- e.g. OFF on hang-up so the unit stops between
// calls while our held activation keeps the session alive for Unity.
//
// RTCAudioSession lives inside the statically-linked liblivekit_ffi; we reach it
// dynamically via NSClassFromString + a protocol-typed id so this file never
// creates a link-time dependency on the class. If the class can't be found we
// fall back to configuring AVAudioSession directly (legacy behavior).

/// Minimal subset of WebRTC's RTCAudioSession that we message dynamically.
@protocol LiveKitRTCAudioSession <NSObject>
@property(nonatomic, assign) BOOL useManualAudio;
@property(nonatomic, assign) BOOL isAudioEnabled;
@property(nonatomic, readonly) int activationCount;
- (void)lockForConfiguration;
- (void)unlockForConfiguration;
- (BOOL)setActive:(BOOL)active error:(NSError**)outError;
- (BOOL)setCategory:(AVAudioSessionCategory)category
mode:(AVAudioSessionMode)mode
options:(AVAudioSessionCategoryOptions)options
error:(NSError**)outError;
@end

// 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;

static const AVAudioSessionCategoryOptions kLiveKitCategoryOptions =
AVAudioSessionCategoryOptionDefaultToSpeaker |
AVAudioSessionCategoryOptionAllowBluetooth |
AVAudioSessionCategoryOptionAllowBluetoothA2DP;

/// 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).
static id<LiveKitRTCAudioSession> LiveKit_RTCSession() {
Class cls = NSClassFromString(@"RTCAudioSession");
if (!cls || ![cls respondsToSelector:@selector(sharedInstance)]) {
return nil;
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
return (id<LiveKitRTCAudioSession>)[cls performSelector:@selector(sharedInstance)];
#pragma clang diagnostic pop
}

extern "C" {

/// Configures the iOS audio session for VoIP/WebRTC use.
/// This sets AVAudioSessionCategoryPlayAndRecord with VoiceChat mode,
/// which enables the VPIO (Voice Processing IO) AudioUnit for:
/// - Hardware echo cancellation (AEC)
/// - Automatic gain control (AGC)
/// - Noise suppression (NS)
/// Configures the iOS audio session for VoIP/WebRTC use and takes app ownership
/// of the shared AVAudioSession.
///
/// Call this before creating PlatformAudio to ensure WebRTC can
/// properly initialize the microphone and speaker.
/// 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.
///
/// 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)
/// to stop the VPIO unit between calls (e.g. on hang-up).
void LiveKit_ConfigureAudioSessionForVoIP() {
AVAudioSession* session = [AVAudioSession sharedInstance];
NSError* error = nil;

// Configure for VoIP with echo cancellation
BOOL success = [session setCategory:AVAudioSessionCategoryPlayAndRecord
mode:AVAudioSessionModeVoiceChat
options:AVAudioSessionCategoryOptionDefaultToSpeaker |
AVAudioSessionCategoryOptionAllowBluetooth |
AVAudioSessionCategoryOptionAllowBluetoothA2DP
error:&error];
id<LiveKitRTCAudioSession> rtc = LiveKit_RTCSession();

if (!success || error) {
NSLog(@"LiveKit: Failed to configure VoIP audio session: %@", error.localizedDescription);
if (rtc == nil) {
// RTCAudioSession unavailable: configure 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)");
return;
}

// Activate the audio session
success = [session setActive:YES error:&error];
if (!success || error) {
NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription);
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);
}

NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VoiceChat mode)");
// 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;
if ([rtc setActive:YES error:&error] && !error) {
s_liveKitHoldsActivation = YES;
} else {
NSLog(@"LiveKit: Failed to activate audio session: %@", error.localizedDescription);
}
}

[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
// unaffected. Callers may still disable it (e.g. on hang-up) via
// LiveKit_SetAudioEnabled(false).
rtc.isAudioEnabled = YES;

NSLog(@"LiveKit: Audio session configured for VoIP (PlayAndRecord + VideoChat, manual mode, activationCount=%d)",
rtc.activationCount);
}

/// Restores the audio session to the default ambient category.
/// Call this when PlatformAudio is disposed if you want to restore
/// the original audio behavior.
/// Enables or disables WebRTC's VPIO audio unit while the app keeps ownership of
/// the session. Pass true when a call connects and false when it ends.
///
/// This is only effective in manual mode (set up by LiveKit_ConfigureAudioSessionForVoIP).
/// Disabling on hang-up stops incoming/outgoing call audio and the VPIO processing,
/// but leaves the session active (via the app's held activation), so Unity audio
/// keeps playing.
void LiveKit_SetAudioEnabled(bool enabled) {
id<LiveKitRTCAudioSession> rtc = LiveKit_RTCSession();
if (rtc == nil) {
return;
}
rtc.isAudioEnabled = enabled ? YES : NO;
NSLog(@"LiveKit: isAudioEnabled=%@ (activationCount=%d)", enabled ? @"YES" : @"NO", rtc.activationCount);
}

/// Restores the audio session to the default ambient category and relinquishes the
/// app-owned activation and manual mode. Call this when PlatformAudio is disposed.
void LiveKit_RestoreDefaultAudioSession() {
id<LiveKitRTCAudioSession> rtc = LiveKit_RTCSession();

if (rtc != nil) {
// Stop the VPIO unit and release our activation before handing control back.
rtc.isAudioEnabled = NO;
if (s_liveKitHoldsActivation) {
NSError* error = nil;
if (![rtc setActive:NO error:&error] || error) {
NSLog(@"LiveKit: Failed to deactivate audio session: %@", error.localizedDescription);
}
s_liveKitHoldsActivation = NO;
}
rtc.useManualAudio = NO;
}

AVAudioSession* session = [AVAudioSession sharedInstance];
NSError* error = nil;

[session setCategory:AVAudioSessionCategoryAmbient error:&error];
if (error) {
NSLog(@"LiveKit: Failed to restore default audio session: %@", error.localizedDescription);
Expand Down
40 changes: 40 additions & 0 deletions Runtime/Scripts/Audio/PlatformAudio.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ internal static class IOSAudioSessionHelper
/// </summary>
[DllImport("__Internal")]
internal static extern void LiveKit_RestoreDefaultAudioSession();

/// <summary>
/// Enables or disables WebRTC's VPIO audio unit while the app keeps
/// ownership of the audio session. Enable when a call connects, disable
/// when it ends. Disabling on hang-up stops call audio without
/// deactivating the session, so other app audio keeps playing.
/// </summary>
[DllImport("__Internal")]
internal static extern void LiveKit_SetAudioEnabled([MarshalAs(UnmanagedType.I1)] bool enabled);
}
#endif

Expand Down Expand Up @@ -354,6 +363,28 @@ public void StopRecording()
Utils.Debug("PlatformAudio: stopped recording");
}

/// <summary>
/// Signals whether call audio should be active on the platform audio session.
///
/// On iOS this gates WebRTC's VPIO audio unit while the app retains ownership
/// of the shared AVAudioSession. It is enabled by default when PlatformAudio is
/// created, so this only needs to be called to <c>false</c> when leaving a room
/// (and back to <c>true</c> when rejoining). Disabling stops the microphone/
/// remote audio path and the hardware voice processing, 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.
///
/// On other platforms this is a no-op: the OS/ADM manages the session directly.
/// </summary>
/// <param name="enabled">True while a call is active, false otherwise.</param>
public void SetSessionAudioEnabled(bool enabled)
{
#if UNITY_IOS && !UNITY_EDITOR
IOSAudioSessionHelper.LiveKit_SetAudioEnabled(enabled);
#endif
Utils.Debug($"PlatformAudio: session audio enabled={enabled}");
}

/// <summary>
/// Releases the PlatformAudio resources.
///
Expand All @@ -364,6 +395,15 @@ public void Dispose()
{
if (_disposed) return;
Handle.Dispose();

#if UNITY_IOS && !UNITY_EDITOR
// Relinquish the app-owned audio session: disable call audio, release
// our activation, leave manual mode, and restore the ambient category.
// Balances the LiveKit_ConfigureAudioSessionForVoIP() call made in the
// constructor so the session isn't left stuck in PlayAndRecord.
IOSAudioSessionHelper.LiveKit_RestoreDefaultAudioSession();
#endif

_disposed = true;
Utils.Debug("PlatformAudio disposed");
}
Expand Down
21 changes: 20 additions & 1 deletion Samples~/Meet/Assets/Runtime/MeetManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ private void OnEndCall()
{
if (_room == null) return;

// Disable call audio while keeping the app-owned audio session active, so
// Unity audio (e.g. background music) survives the hang-up on iOS.
if (usePlatformAudio)
_platformAudio?.SetSessionAudioEnabled(false);

_room.Disconnect();
CleanUpAllTracks();
_room = null;
Expand Down Expand Up @@ -249,6 +254,13 @@ private IEnumerator ConnectToRoom()
_localId = _room.LocalParticipant.Identity;
buttonBar.SetConnected(true);

// Enable call audio now that we're in a room. On iOS this turns on WebRTC's
// VPIO unit while the app keeps ownership of the audio session; leaving the
// room disables it again (see OnEndCall / OnDisconnected) so other Unity
// audio keeps playing.
if (usePlatformAudio)
_platformAudio?.SetSessionAudioEnabled(true);

EnsureParticipantTile(_localId);
foreach (var remote in _room.RemoteParticipants.Values)
EnsureParticipantTile(remote.Identity);
Expand Down Expand Up @@ -433,7 +445,14 @@ private void OnParticipantDisconnected(Participant participant, DisconnectReason
}

private void OnDisconnected(Room room)
=> Debug.Log($"Disconnected from room: {room.DisconnectReason}");
{
Debug.Log($"Disconnected from room: {room.DisconnectReason}");

// Covers server-initiated disconnects as well as OnEndCall; idempotent with
// the call already made there. Keeps the audio session active for Unity.
if (usePlatformAudio)
_platformAudio?.SetSessionAudioEnabled(false);
}

private void OnTrackMuted(TrackPublication publication, Participant participant)
{
Expand Down
Loading