using System; using System.Collections; using System.Collections.Generic; using Epic.OnlineServices; using Epic.OnlineServices.Lobby; using Epic.OnlineServices.RTC; using Epic.OnlineServices.RTCAudio; using UnityEngine; /// /// Generic reusable cross-platform class to provide common voice chat functionality based on the EOS Voice Chat service. /// This class does not know anything about EOS platform initialization or authentication; it just takes the required /// EOS interfaces and exposes a number of game-related voice functions. /// public class EOSVoiceChat: IDisposable { private const uint DefaultMaxChatPlayers = 16; // "Lobbies that generate conference rooms must have <= 16 max players" private readonly LobbyInterface lobbyInterface; private readonly RTCInterface rtcInterface; private readonly RTCAudioInterface audioInterface; private readonly Func localUserProvider; private readonly Dictionary chatUsers = new Dictionary(); private string connectedLobbyId; private string rtcRoomName; public bool IsConnected => lobbyInterface != null && rtcInterface != null && audioInterface != null && !string.IsNullOrEmpty(connectedLobbyId); private string defaultInputDeviceId; public bool HasInputDevice => !string.IsNullOrEmpty(defaultInputDeviceId); private ulong? onAudioDevicesChangedCallbackId; private ulong? onConnectionChangedCallbackId, onParticipantStatusChangedCallbackId, onParticipantUpdatedCallbackId; public event Action OnChatConnected; public event Action OnChatConnectionFailed; public event Action OnChatDisconnected; public event Action OnChatUserJoined; public event Action OnChatUserLeft; /// /// Provide the required interfaces for voice chat. Product User ID is provided through a callback, so that the /// same instance of this class can remain in use even if the logged in user changes. /// public EOSVoiceChat( LobbyInterface lobbyInterface, RTCInterface rtcInterface, RTCAudioInterface audioInterface, Func localUserProvider) { this.lobbyInterface = lobbyInterface; this.rtcInterface = rtcInterface; this.audioInterface = audioInterface; this.localUserProvider = localUserProvider; SubscribeToAudioDeviceNotifications(); } public void Dispose() { DisconnectChat(); UnsubscribeFromAudioDeviceNotifications(); } /// /// Join an existing chat lobby or create a new one. /// public void ConnectToChat(string chatLobbyName, uint maxChatPlayers = DefaultMaxChatPlayers) { DisconnectChat(); // Leave any currently connected chat lobby var connectArgs = new ChatConnectArgs(chatLobbyName, maxChatPlayers); var createSearchOptions = new CreateLobbySearchOptions { MaxResults = 1 }; lobbyInterface.CreateLobbySearch(ref createSearchOptions, out var searchHandle); var setLobbyIdOptions = new LobbySearchSetLobbyIdOptions { LobbyId = chatLobbyName }; searchHandle.SetLobbyId(ref setLobbyIdOptions); var localUserId = localUserProvider.Invoke(); if (localUserId == null) return; var searchOptions = new LobbySearchFindOptions { LocalUserId = localUserId }; searchHandle.Find(ref searchOptions, null, LobbySearchOnFindCallback); void LobbySearchOnFindCallback(ref LobbySearchFindCallbackInfo findData) { switch (findData.ResultCode) { case Result.Success: var copyOptions = new LobbySearchCopySearchResultByIndexOptions { LobbyIndex = 0 }; searchHandle.CopySearchResultByIndex(ref copyOptions, out var lobbyDetails); Debug.Log("Found existing chat lobby, joining..."); var joinLobbyOptions = new JoinLobbyOptions { LocalUserId = localUserId, LobbyDetailsHandle = lobbyDetails, PresenceEnabled = false, }; lobbyInterface.JoinLobby(ref joinLobbyOptions, connectArgs, HandleLobbyJoined); break; default: Debug.Log($"Creating new chat lobby..."); var createLobbyOptions = new CreateLobbyOptions { LocalUserId = localUserId, AllowInvites = false, PermissionLevel = LobbyPermissionLevel.Publicadvertised, PresenceEnabled = false, MaxLobbyMembers = maxChatPlayers, DisableHostMigration = false, LobbyId = chatLobbyName, BucketId = Application.productName, // TODO: do we need anything more specific than this? EnableRTCRoom = true, }; lobbyInterface.CreateLobby(ref createLobbyOptions, connectArgs, HandleLobbyCreated); break; } } } private void HandleLobbyCreated(ref CreateLobbyCallbackInfo data) { var connectArgs = (ChatConnectArgs)data.ClientData; switch (data.ResultCode) { case Result.Success: connectedLobbyId = data.LobbyId; UpdateRTCRoomName(); Debug.Log($"Chat lobby creation successful, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}"); chatUsers.Clear(); SubscribeToRoomNotifications(); OnChatConnected?.Invoke(); break; case Result.LobbyLobbyAlreadyExists: // This can happen if two clients try to create the same lobby at the same time, a classic race condition. // Try to join the other client's newly created chat lobby instead. connectedLobbyId = null; rtcRoomName = null; Debug.Log("Chat lobby already exists, attempting to join it..."); ConnectToChat(connectArgs.chatLobbyName, connectArgs.maxChatPlayers); break; default: Debug.LogError($"Chat lobby creation failed, result code = {data.ResultCode}"); connectedLobbyId = null; rtcRoomName = null; OnChatConnectionFailed?.Invoke(); break; } } private void HandleLobbyJoined(ref JoinLobbyCallbackInfo data) { switch (data.ResultCode) { case Result.Success: connectedLobbyId = data.LobbyId; UpdateRTCRoomName(); Debug.Log($"Chat lobby joined successfully, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}"); chatUsers.Clear(); SubscribeToRoomNotifications(); OnChatConnected?.Invoke(); break; default: Debug.LogError($"Chat lobby join failed, result code = {data.ResultCode}"); connectedLobbyId = null; rtcRoomName = null; OnChatConnectionFailed?.Invoke(); break; } } private void UpdateRTCRoomName() { rtcRoomName = null; var getRoomNameOptions = new GetRTCRoomNameOptions { LocalUserId = localUserProvider.Invoke(), LobbyId = connectedLobbyId }; var result = lobbyInterface.GetRTCRoomName(ref getRoomNameOptions, out Utf8String roomName); if (result != Result.Success) { Debug.LogError($"Failed to obtain RTC room name for lobby with ID {connectedLobbyId}"); return; } rtcRoomName = roomName; } public void DisconnectChat() { if (!IsConnected) return; // Unsubscribing first means we don't get a Disconnected notification for intentionally leaving the lobby UnsubscribeFromRoomNotifications(); void OnLeaveLobbyCallback(ref LeaveLobbyCallbackInfo data) { } var leaveLobbyOptions = new LeaveLobbyOptions { LocalUserId = localUserProvider.Invoke(), LobbyId = connectedLobbyId, }; lobbyInterface.LeaveLobby(ref leaveLobbyOptions, null, OnLeaveLobbyCallback); connectedLobbyId = null; rtcRoomName = null; chatUsers.Clear(); OnChatDisconnected?.Invoke(); } /// /// Mute or unmute the local player's voice chat. This can be used to implement push-to-talk. /// public void SetLocalMuted(bool muted) { if (!IsConnected) return; var updateSendingOptions = new UpdateSendingOptions { LocalUserId = localUserProvider.Invoke(), RoomName = rtcRoomName, AudioStatus = muted ? RTCAudioStatus.Disabled : RTCAudioStatus.Enabled, }; audioInterface.UpdateSending(ref updateSendingOptions, null, OnUpdateSendingCallback); void OnUpdateSendingCallback(ref UpdateSendingCallbackInfo data) { } } /// /// Mute or unmute a specific remove player. This can be used to filter out specific players in the chat lobby, /// or for manually muting toxic players. /// public void SetRemoteMuted(ProductUserId remoteUser, bool muted) { if (!IsConnected) return; var updateReceivingOptions = new UpdateReceivingOptions { LocalUserId = localUserProvider.Invoke(), ParticipantId = remoteUser, RoomName = rtcRoomName, AudioEnabled = !muted, }; audioInterface.UpdateReceiving(ref updateReceivingOptions, null, OnUpdateReceivingCallback); void OnUpdateReceivingCallback(ref UpdateReceivingCallbackInfo data) { } } /// /// Set all remote players to muted. This can be used during loading screens, or to initialize the chat lobby /// when chatting with only a small subset of players. /// public void MuteAllRemote() { if (!IsConnected) return; var localUserId = localUserProvider.Invoke(); foreach (var remoteProductUser in chatUsers.Keys) { var updateReceivingOptions = new UpdateReceivingOptions { LocalUserId = localUserId, ParticipantId = remoteProductUser, RoomName = rtcRoomName, AudioEnabled = false, }; audioInterface.UpdateReceiving(ref updateReceivingOptions, null, OnUpdateReceivingCallback); } void OnUpdateReceivingCallback(ref UpdateReceivingCallbackInfo data) { } } /// /// Set global voice chat volume. Values range between 0.0 (muted) and 1.0 (full volume). /// If desired, volume value can be increased up to 2.0 for an overdrive mode. /// public void SetOutputVolume(float volume) { var setAudioOptions = new SetAudioOutputSettingsOptions { LocalUserId = localUserProvider.Invoke(), DeviceId = null, // Default output device Volume = volume * 50f, }; audioInterface.SetAudioOutputSettings(ref setAudioOptions); } /// /// Whether the requested user is currently talking or not. /// This can be either the local player or a remote user. /// public bool IsUserSpeaking(ProductUserId user) { if (chatUsers.TryGetValue(user, out var chatUser)) { return chatUser.audioStatus == RTCAudioStatus.Enabled && chatUser.isSpeaking; } return false; } private void SubscribeToAudioDeviceNotifications() { if (!onAudioDevicesChangedCallbackId.HasValue) { var addNotifyOptions = new AddNotifyAudioDevicesChangedOptions(); onAudioDevicesChangedCallbackId = audioInterface.AddNotifyAudioDevicesChanged( ref addNotifyOptions, null, HandleAudioDevicesChanged); // Call the event handler once to query the audio devices in their initial state AudioDevicesChangedCallbackInfo callbackInfo; HandleAudioDevicesChanged(ref callbackInfo); } } private void UnsubscribeFromAudioDeviceNotifications() { if (onAudioDevicesChangedCallbackId.HasValue) { audioInterface.RemoveNotifyAudioDevicesChanged(onAudioDevicesChangedCallbackId.Value); onAudioDevicesChangedCallbackId = null; } } private void HandleAudioDevicesChanged(ref AudioDevicesChangedCallbackInfo data) { defaultInputDeviceId = null; var sb = new System.Text.StringBuilder(); // Update the default input device, so we know whether we can actually talk or not var getInputDevicesOptions = new GetAudioInputDevicesCountOptions(); uint inputDevicesCount = audioInterface.GetAudioInputDevicesCount(ref getInputDevicesOptions); sb.AppendLine($"Found {inputDevicesCount} audio input device(s):"); for (uint inputDeviceIndex = 0; inputDeviceIndex < inputDevicesCount; ++inputDeviceIndex) { var getDeviceOptions = new GetAudioInputDeviceByIndexOptions { DeviceInfoIndex = inputDeviceIndex }; var inputDeviceInfo = audioInterface.GetAudioInputDeviceByIndex(ref getDeviceOptions); sb.AppendLine($"Input device {inputDeviceIndex}: ID = {inputDeviceInfo?.DeviceId}, Name = {inputDeviceInfo?.DeviceName}, Default = {inputDeviceInfo?.DefaultDevice}"); if (inputDeviceInfo?.DefaultDevice ?? false) { defaultInputDeviceId = inputDeviceInfo.Value.DeviceId; } } var getOutputDevicesOptions = new GetAudioOutputDevicesCountOptions(); uint outputDevicesCount = audioInterface.GetAudioOutputDevicesCount(ref getOutputDevicesOptions); sb.AppendLine($"Found {outputDevicesCount} audio output device(s):"); for (uint outputDeviceIndex = 0; outputDeviceIndex < outputDevicesCount; ++outputDeviceIndex) { var getDeviceOptions = new GetAudioOutputDeviceByIndexOptions { DeviceInfoIndex = outputDeviceIndex }; var outputDeviceInfo = audioInterface.GetAudioOutputDeviceByIndex(ref getDeviceOptions); sb.AppendLine($"Output device {outputDeviceIndex}: ID = {outputDeviceInfo?.DeviceId}, Name = {outputDeviceInfo?.DeviceName}, Default = {outputDeviceInfo?.DefaultDevice}"); } Debug.Log(sb); } private void SubscribeToRoomNotifications() { var localUserId = localUserProvider.Invoke(); if (!onConnectionChangedCallbackId.HasValue) { var addNotifyOptions = new AddNotifyRTCRoomConnectionChangedOptions(); onConnectionChangedCallbackId = lobbyInterface.AddNotifyRTCRoomConnectionChanged( ref addNotifyOptions, null, HandleConnectionChanged); } if (!onParticipantStatusChangedCallbackId.HasValue) { var addNotifyOptions = new AddNotifyParticipantStatusChangedOptions { LocalUserId = localUserId, RoomName = rtcRoomName, }; onParticipantStatusChangedCallbackId = rtcInterface.AddNotifyParticipantStatusChanged( ref addNotifyOptions, null, HandleParticipantStatusChanged); } if (!onParticipantUpdatedCallbackId.HasValue) { var addNotifyOptions = new AddNotifyParticipantUpdatedOptions { LocalUserId = localUserId, RoomName = rtcRoomName, }; onParticipantUpdatedCallbackId = audioInterface.AddNotifyParticipantUpdated( ref addNotifyOptions, null, HandleParticipantUpdated); } } private void UnsubscribeFromRoomNotifications() { if (onConnectionChangedCallbackId.HasValue) { rtcInterface.RemoveNotifyDisconnected(onConnectionChangedCallbackId.Value); onConnectionChangedCallbackId = null; } if (onParticipantStatusChangedCallbackId.HasValue) { rtcInterface.RemoveNotifyParticipantStatusChanged(onParticipantStatusChangedCallbackId.Value); onParticipantStatusChangedCallbackId = null; } if (onParticipantUpdatedCallbackId.HasValue) { audioInterface.RemoveNotifyParticipantUpdated(onParticipantUpdatedCallbackId.Value); onParticipantUpdatedCallbackId = null; } } private void HandleConnectionChanged(ref RTCRoomConnectionChangedCallbackInfo data) { if (data.LobbyId != connectedLobbyId) return; // Note: reconnecting is handled automatically by the RTC lobby system if (!data.IsConnected && (data.DisconnectReason == Result.UserKicked || data.DisconnectReason == Result.UserBanned)) { // If we're either kicked or banned then we won't automatically reconnect, so leave the lobby properly DisconnectChat(); } } private void HandleParticipantStatusChanged(ref ParticipantStatusChangedCallbackInfo data) { switch (data.ParticipantStatus) { case RTCParticipantStatus.Joined: if (!chatUsers.ContainsKey(data.ParticipantId)) { var chatUser = new ChatUser(data.ParticipantId); chatUsers.Add(data.ParticipantId, chatUser); OnChatUserJoined?.Invoke(data.ParticipantId); } break; case RTCParticipantStatus.Left: chatUsers.Remove(data.ParticipantId); OnChatUserLeft?.Invoke(data.ParticipantId); break; } } private void HandleParticipantUpdated(ref ParticipantUpdatedCallbackInfo data) { if (chatUsers.TryGetValue(data.ParticipantId, out var chatUser)) { chatUser.audioStatus = data.AudioStatus; chatUser.isSpeaking = data.Speaking; } } private class ChatConnectArgs { public readonly string chatLobbyName; public readonly uint maxChatPlayers; public ChatConnectArgs(string chatLobbyName, uint maxChatPlayers) { this.chatLobbyName = chatLobbyName; this.maxChatPlayers = maxChatPlayers; } } private class ChatUser { public readonly ProductUserId productUserId; public RTCAudioStatus audioStatus; public bool isSpeaking; public ChatUser(ProductUserId productUserId) { this.productUserId = productUserId; } } }