XRoom_Unity/Assets/Photon/PhotonVoice/Code/UnityVoiceClient.cs
2025-05-31 10:20:20 +03:30

593 lines
23 KiB
C#

// ----------------------------------------------------------------------------
// <copyright file="VoiceConnection.cs" company="Exit Games GmbH">
// Photon Voice for Unity - Copyright (C) 2018 Exit Games GmbH
// </copyright>
// <summary>
// Component that represents a client voice connection to Photon Servers.
// </summary>
// <author>developer@photonengine.com</author>
// ----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using ExitGames.Client.Photon;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.Serialization;
namespace Photon.Voice.Unity
{
/// <summary> Component that represents a Voice client and manages a simple Unity integration: a single Recorder and multiple remote speakers. </summary>
[AddComponentMenu("Photon Voice/Unity Voice Client")]
[HelpURL("https://doc.photonengine.com/en-us/voice/v2/getting-started/voice-intro")]
public class UnityVoiceClient : VoiceConnection
{
public override bool AlwaysUsePrimaryRecorder => true;
/// <summary>
/// Whether or not to use the Voice AppId and all the other AppSettings from Fusion's RealtimeAppSettings ScriptableObject singleton in the Voice client/app.
/// </summary>
[field: SerializeField]
public bool UseVoiceAppSettings = false;
protected virtual void Start()
{
if (this.PrimaryRecorder != null)
{
AddRecorder(this.PrimaryRecorder);
}
}
public override bool ConnectUsingSettings(AppSettings overwriteSettings = null)
{
if (overwriteSettings != null)
{
return base.ConnectUsingSettings(overwriteSettings);
}
if (this.UseVoiceAppSettings)
{
return base.ConnectUsingSettings(PhotonAppSettings.Instance.AppSettings);
}
else
{
return base.ConnectUsingSettings();
}
}
protected override Speaker InstantiateSpeakerForRemoteVoice(int playerId, byte voiceId, object userData)
{
// Create a new Speaker on each OnRemoteVoiceInfo() call
return this.InstantiateSpeakerPrefab(this.gameObject, true);
}
}
[DisallowMultipleComponent]
/// <summary> Component that represents a Voice client. </summary>
public class VoiceConnection : ConnectionHandler
{
/// <summary>Recommended Photon Transport channel for audio. Chosen not to interfere with video and default channel.</summary>
public const int ChannelAudio = 1;
/// <summary>Recommended Photon Transport channel for video. Chosen not to interfere with audio and default channel.</summary>
public const int ChannelVideo = 2;
#region Private Fields
// VoiceComponentImpl instance instead if VoiceComponent inheritance
private VoiceComponentImpl voiceComponentImpl = new VoiceComponentImpl();
/// <summary>Key to save the "Best Region Summary" in the Player Preferences.</summary>
private const string PlayerPrefsKey = "VoiceCloudBestRegion";
private LoadBalancingTransport client;
private SupportLogger supportLoggerComponent;
[SerializeField]
private bool runInBackground = true;
/// <summary>
/// time [ms] between statistics calculations
/// </summary>
[SerializeField]
private int statsResetInterval = 1000;
private int nextStatsTickCount = Environment.TickCount;
private float statsReferenceTime;
private int referenceFramesLost;
private int referenceFramesReceived;
[SerializeField]
private GameObject speakerPrefab;
private List<RemoteVoiceLink> cachedRemoteVoices = new List<RemoteVoiceLink>();
[SerializeField]
[FormerlySerializedAs("PrimaryRecorder")]
private Recorder primaryRecorder;
/// <summary>
/// If true, <see cref="VoiceConnection.PrimaryRecorder"/> will be used by this VoiceConnection instnance directly.
/// </summary>
[SerializeField]
[Tooltip("Use primary recorder directly by Voice Client")]
private bool usePrimaryRecorder;
[SerializeField]
[Tooltip("Use the protocol compatible with Photon Voice C++ API")]
private bool cppCompatibilityMode;
// to allow VoiceConnection ignore usePrimaryRecorder and do not show it in Editor
public virtual bool AlwaysUsePrimaryRecorder => false;
private List<Speaker> linkedSpeakers = new List<Speaker>();
private List<Recorder> recorders = new List<Recorder>();
#endregion
private void Init()
{
this.client = new LoadBalancingTransport2(this.Logger, ConnectionProtocol.Udp, cppCompatibilityMode);
this.client.VoiceClient.OnRemoteVoiceInfoAction += this.OnRemoteVoiceInfo;
this.client.StateChanged += this.OnVoiceStateChanged;
this.client.OpResponseReceived += this.OnOperationResponseReceived;
base.Client = this.client;
this.StartFallbackSendAckThread();
}
#region Public Fields
/// <summary> Settings to be used by this Voice Client</summary>
public AppSettings Settings;
#if UNITY_EDITOR
[HideInInspector]
public bool ShowSettings = true;
#endif
/// <summary> Fires when a speaker has been linked to a remote audio stream</summary>
public event Action<Speaker> SpeakerLinked;
/// <summary> Fires when a remote voice stream is added</summary>
public event Action<RemoteVoiceLink> RemoteVoiceAdded;
#if UNITY_PS4 || UNITY_PS5
/// <summary>PlayStation user ID of the local user</summary>
/// <remarks>Pass the userID of the local PlayStation user who should receive any incoming audio. This value is used by Photon Voice when sending output to the headphones on the PlayStation.
/// If you don't provide a user ID, then Photon Voice uses the user ID of the user at index 0 in the list of local users
/// and in case that there are multiple local users, the audio output might be sent to the headphones of a different user than intended.</remarks>
public int PlayStationUserID = 0; // set from your game's code
#endif
#endregion
#region Properties
public Voice.ILogger Logger => voiceComponentImpl.Logger;
// to set logging level from code
public VoiceLogger VoiceLogger => voiceComponentImpl.VoiceLogger;
public new LoadBalancingTransport Client { get { return this.client; } }
/// <summary>Returns underlying Photon Voice client.</summary>
public VoiceClient VoiceClient { get { return this.Client.VoiceClient; } }
/// <summary>Returns Photon Voice client state.</summary>
public ClientState ClientState { get { return this.Client.State; } }
/// <summary>Number of frames received per second.</summary>
public float FramesReceivedPerSecond { get; private set; }
/// <summary>Number of frames lost per second.</summary>
public float FramesLostPerSecond { get; private set; }
/// <summary>Percentage of lost frames.</summary>
public float FramesLostPercent { get; private set; }
/// <summary> Prefab that contains Speaker component to be instantiated when receiving a new remote audio source info</summary>
public GameObject SpeakerPrefab
{
get => this.speakerPrefab;
set => this.speakerPrefab = value;
}
#if UNITY_EDITOR
public List<RemoteVoiceLink> CachedRemoteVoices
{
get { return this.cachedRemoteVoices; }
}
#endif
/// <summary>
/// Primary Recorder to be used by VoiceConnection implementations directly or via integration objects.
/// </summary>
public Recorder PrimaryRecorder
{
get => this.primaryRecorder;
set => this.primaryRecorder = value;
}
/// <summary>
/// Use <see cref="VoiceConnection.PrimaryRecorder"/> directly.
/// </summary>
public bool UsePrimaryRecorder => this.usePrimaryRecorder;
/// <summary>Used to store and access the "Best Region Summary" in the Player Preferences.</summary>
public string BestRegionSummaryInPreferences
{
get
{
return PlayerPrefs.GetString(PlayerPrefsKey, null);
}
set
{
if (string.IsNullOrEmpty(value))
{
PlayerPrefs.DeleteKey(PlayerPrefsKey);
}
else
{
PlayerPrefs.SetString(PlayerPrefsKey, value);
}
}
}
#endregion
#region Public Methods
/// <summary>
/// Connect to Photon server using <see cref="Settings"/>
/// </summary>
/// <param name="overwriteSettings">Overwrites <see cref="Settings"/> before connecting</param>
/// <returns>If true voice connection command was sent from client</returns>
public virtual bool ConnectUsingSettings(AppSettings overwriteSettings = null)
{
if (this.Client.LoadBalancingPeer.PeerState != PeerStateValue.Disconnected)
{
this.Logger.Log(LogLevel.Warning, "ConnectUsingSettings() failed. Can only connect while in state 'Disconnected'. Current state: {0}", this.Client.LoadBalancingPeer.PeerState);
return false;
}
if (overwriteSettings != null)
{
this.Settings = overwriteSettings;
}
if (this.Settings == null)
{
this.Logger.Log(LogLevel.Error, "Settings are null");
return false;
}
if (string.IsNullOrEmpty(this.Settings.AppIdVoice) && string.IsNullOrEmpty(this.Settings.Server))
{
this.Logger.Log(LogLevel.Error, "Provide an AppId or a Server address in Settings to be able to connect");
return false;
}
if (this.Settings.IsMasterServerAddress && string.IsNullOrEmpty(this.Client.UserId))
{
this.Client.UserId = Guid.NewGuid().ToString(); // this is a workaround to use when connecting to self-hosted Photon Server v4, which does not return a UserId to the client if generated randomly server side
}
if (string.IsNullOrEmpty(this.Settings.BestRegionSummaryFromStorage))
{
this.Settings.BestRegionSummaryFromStorage = this.BestRegionSummaryInPreferences;
}
return this.client.ConnectUsingSettings(this.Settings);
}
/// <summary>
/// Tries to link local Speaker with remote voice stream using UserData.
/// Useful if Speaker created after stream is started.
/// </summary>
/// <param name="speaker">Speaker ot try linking.</param>
/// <param name="userData">UserData object used to bind local Speaker with remote voice stream.</param>
/// <returns></returns>
public bool AddSpeaker(Speaker speaker, object userData)
{
for (int i = 0; i < this.cachedRemoteVoices.Count; i++)
{
RemoteVoiceLink rvl = this.cachedRemoteVoices[i];
if (userData.Equals(rvl.VoiceInfo.UserData))
{
this.Logger.Log(LogLevel.Debug, "Speaker linking for remoteVoice {0}.", rvl);
this.LinkSpeaker(speaker, rvl);
return speaker.IsLinked;
}
}
return false;
}
#endregion
#region Private Methods
protected override void Awake()
{
base.Awake();
voiceComponentImpl.Awake(this);
Init();
if (this.ApplyDontDestroyOnLoad)
{
// also apply to the relevant VoiceLogger
DontDestroyOnLoad(voiceComponentImpl.VoiceLogger.gameObject);
}
this.supportLoggerComponent = this.GetComponent<SupportLogger>();
if (this.supportLoggerComponent != null)
{
this.supportLoggerComponent.Client = this.Client;
this.supportLoggerComponent.LogTrafficStats = true;
}
if (this.runInBackground)
{
Application.runInBackground = this.runInBackground;
}
}
protected virtual void Update()
{
this.VoiceClient.Service();
}
protected virtual void FixedUpdate()
{
while (this.Client.LoadBalancingPeer.DispatchIncomingCommands()) ;
}
private void LateUpdate()
{
while (this.Client.LoadBalancingPeer.SendOutgoingCommands()) ;
if (this.statsResetInterval > 0)
{
int currentMsSinceStart = Environment.TickCount; // avoiding Environment.TickCount, which could be negative on long-running platforms
if (currentMsSinceStart - this.nextStatsTickCount > 0)
{
this.CalcStatistics();
this.nextStatsTickCount = currentMsSinceStart + this.statsResetInterval;
}
}
}
protected virtual void OnDestroy()
{
this.client.StateChanged -= this.OnVoiceStateChanged;
this.client.OpResponseReceived -= this.OnOperationResponseReceived;
this.client.Disconnect();
if (this.client.LoadBalancingPeer != null)
{
this.client.LoadBalancingPeer.Disconnect();
this.client.LoadBalancingPeer.StopThread();
}
this.client.Dispose();
}
protected virtual Speaker InstantiateSpeakerForRemoteVoice(int playerId, byte voiceId, object userData)
{
throw new Exception("FindSpeakerByUserData: VoiceConnection does not provide userData linkage");
}
/// <summary>
/// Instantiates <see cref="SpeakerPrefab"/>, optionally attaches it to the provided parent.
/// </summary>
/// <remarks>
/// VoiceConnection manages the instantiated object (destroys on OnRemoteVoiceRemoveAction).
/// </remarks>
/// <param name="parent">The object to attach Steaker to.</param>
/// <param name="destroyOnRemove">Automatically destroy instantiated prefab when remote voice is removed (the caller does not manages the instance).</param>
/// <returns>Instantiated Speaker or null.</returns>
public Speaker InstantiateSpeakerPrefab(GameObject parent, bool destroyOnRemove)
{
if (this.SpeakerPrefab == null)
{
this.Logger.Log(LogLevel.Error, "SpeakerPrefab is not set.");
return null;
}
var go = Instantiate(this.SpeakerPrefab);
Speaker[] speakers = go.GetComponentsInChildren<Speaker>(true);
if (speakers.Length > 0)
{
if (speakers.Length > 1)
{
this.Logger.Log(LogLevel.Warning, "Multiple Speaker components found attached to the GameObject (VoiceConnection.SpeakerPrefab) or its children. Using the first one we found.");
}
if (destroyOnRemove)
{
speakers[0].OnRemoteVoiceRemoveAction += (s) =>
{
this.Logger.Log(LogLevel.Info, "OnRemoteVoiceRemoveAction: destroying VoiceConnection.SpeakerPrefab instance [{0}]", go.name);
Destroy(go);
};
}
if (parent != null)
{
if(parent.transform.Find("Headset"))
go.transform.SetParent(parent.transform.Find("Headset"), false);
else
go.transform.SetParent(parent.transform, false);
}
this.Logger.Log(LogLevel.Info, "Instance of VoiceConnection.SpeakerPrefab instantiated.");
return speakers[0];
}
else
{
this.Logger.Log(LogLevel.Error, "SpeakerPrefab does not have a component of type Speaker in its hierarchy.");
Destroy(go);
return null;
}
}
private void OnRemoteVoiceInfo(int channelId, int playerId, byte voiceId, VoiceInfo voiceInfo, ref RemoteVoiceOptions options)
{
if (voiceInfo.Codec != Codec.AudioOpus)
{
this.Logger.Log(LogLevel.Info, "OnRemoteVoiceInfo: Skipped as codec is not Opus, [p#{0} v#{1} c#{2} i:{{{3}}}]", playerId, voiceId, channelId, voiceInfo);
return;
}
RemoteVoiceLink remoteVoice = new RemoteVoiceLink(voiceInfo, playerId, voiceId, channelId, ref options);
if (Application.platform == RuntimePlatform.WebGLPlayer)
{
#if !UNITY_2021_2_OR_NEWER // opus lib requires Emscripten 2.0.19
this.Logger.Log(LogLevel.Error, "Remote voice Opus decoder requies Unity 2021.2 or newer for WebGL");
options.Decoder = null; // null Opus decoder set by RemoteVoiceLink
#endif
}
this.Logger.Log(LogLevel.Info, "OnRemoteVoiceInfo: {0}", remoteVoice);
this.cachedRemoteVoices.Add(remoteVoice);
if (RemoteVoiceAdded != null)
{
RemoteVoiceAdded(remoteVoice);
}
remoteVoice.RemoteVoiceRemoved += () =>
{
this.Logger.Log(LogLevel.Info, "OnRemoteVoiceInfo: RemoteVoiceRemoved {0}", remoteVoice);
this.cachedRemoteVoices.Remove(remoteVoice);
};
var speaker = this.InstantiateSpeakerForRemoteVoice(playerId, voiceId, voiceInfo.UserData);
if (speaker == null)
{
this.Logger.Log(LogLevel.Debug, "OnRemoteVoiceInfo: Remote GameObject not found or does not have a Speaker {0}", remoteVoice);
}
else
{
speaker.Name = string.Format("Remote p#{0} v#{1}", playerId, voiceId);
this.LinkSpeaker(speaker, remoteVoice);
}
}
protected virtual void OnVoiceStateChanged(ClientState fromState, ClientState toState)
{
this.Logger.Log(LogLevel.Info, "OnVoiceStateChanged from {0} to {1}", fromState, toState);
if (fromState == ClientState.Joined)
{
for (int i = 0; i < this.recorders.Count; i++)
{
Recorder rec = this.recorders[i];
if (rec.RecordWhenJoined)
{
rec.RecordingEnabled = false;
}
}
this.cachedRemoteVoices.Clear();
}
switch (toState)
{
case ClientState.ConnectedToMasterServer:
{
if (this.Client.RegionHandler != null)
{
if (this.Settings != null)
{
this.Settings.BestRegionSummaryFromStorage = this.Client.RegionHandler.SummaryToCache;
}
this.BestRegionSummaryInPreferences = this.Client.RegionHandler.SummaryToCache;
}
break;
}
case ClientState.Joined:
{
for (int i = 0; i < this.recorders.Count; i++)
{
Recorder rec = this.recorders[i];
if (rec.RecordWhenJoined)
{
rec.RecordingEnabled = true;
}
}
break;
}
}
}
protected void CalcStatistics()
{
float now = Time.time;
int recv = this.VoiceClient.FramesReceived - this.referenceFramesReceived;
int lost = this.VoiceClient.FramesLost - this.referenceFramesLost;
float t = now - this.statsReferenceTime;
if (t > 0f)
{
if (recv + lost > 0)
{
this.FramesReceivedPerSecond = recv / t;
this.FramesLostPerSecond = lost / t;
this.FramesLostPercent = 100f * lost / (recv + lost);
}
else
{
this.FramesReceivedPerSecond = 0f;
this.FramesLostPerSecond = 0f;
this.FramesLostPercent = 0f;
}
}
this.referenceFramesReceived = this.VoiceClient.FramesReceived;
this.referenceFramesLost = this.VoiceClient.FramesLost;
this.statsReferenceTime = now;
}
private void LinkSpeaker(Speaker speaker, RemoteVoiceLink remoteVoice)
{
#if UNITY_PS4 || UNITY_PS5
speaker.PlayStationUserID = this.PlayStationUserID;
#endif
if (speaker.Link(remoteVoice))
{
this.Logger.Log(LogLevel.Info, "Speaker linked with remote voice {0}", remoteVoice);
this.linkedSpeakers.Add(speaker);
remoteVoice.RemoteVoiceRemoved += () =>
{
this.linkedSpeakers.Remove(speaker);
};
if (SpeakerLinked != null)
{
SpeakerLinked(speaker);
}
}
}
public bool AddRecorder(Recorder rec)
{
if (!this.recorders.Contains(rec))
{
if (rec.Init(this))
{
this.recorders.Add(rec);
return true;
}
else
{
this.Logger.Log(LogLevel.Warning, "AddRecorder: failed to init recorder {0}.", rec);
}
}
else
{
this.Logger.Log(LogLevel.Error, "AddRecorder: recorder {0} already added.", rec);
}
return false;
}
public void RemoveRecorder(Recorder rec)
{
if (rec != null)
{
rec.Deinit(this);
this.recorders.Remove(rec);
}
}
protected virtual void OnOperationResponseReceived(OperationResponse operationResponse)
{
if (operationResponse.ReturnCode != ErrorCode.Ok && (operationResponse.OperationCode != OperationCode.JoinRandomGame || operationResponse.ReturnCode == ErrorCode.NoRandomMatchFound))
{
this.Logger.Log(LogLevel.Error, "Operation {0} response error code {1} message {2}", operationResponse.OperationCode, operationResponse.ReturnCode, operationResponse.DebugMessage);
}
}
#endregion
}
}