【Unity・VoicePro】WebGLで3Dサウンド（音量減衰）ボイスチャットを実現する

:::note
English : https://qiita.com/konbu9640/items/b7f42543814035f39074
:::

# VoiceProとは
[VoicePro](https://assetstore.unity.com/packages/tools/input-management/voice-pro-webgl-mobile-desktop-169274)はWebGLビルドでも音声チャットが行えるようになる有料アセットです。これとPUN2を組み合わせた実装を行っています。

# 3Dサウンドを実現するには
各プレイヤーが発声する際、VoiceProオブジェクトの子オブジェクトにAudioSource付きのオブジェクトが付加されます。ここでは、これを<strong>Voice Clip</strong>と呼びます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2602537/db9c877f-0210-b84b-2b08-5166dbdadaa1.png)

我々がするべきことは、下記の流れになります。

:::note
実装だけ知りたい人は次のセクションまで飛ばしてくださって大丈夫です。
:::

Voice Clipを対応するプレイヤーのオブジェクトへもっていく
-> Voice Clipとクライアントの距離に応じて音量を調節する。

### Voice Clipを対応するプレイヤーのオブジェクトへもっていく
これがなかなか厄介です。
まずは、どのVoice Clipがどのプレイヤーに対応するのかを取得する必要があります。この識別にはVoiceProのネットワークIDを使用します。
具体的には、VoiceProアセットにネットワークIDを記録するpublic変数を保持するように書き加えて、クライアントはプレイヤー、Voice ClipがそれぞれそのネットワークIDを取得します。クライアント以外（他人）のネットワークIDをPUN RPCで同期することにより取得して、識別できます。

Voice Clipの親オブジェクトをプレイヤーのオブジェクトにしてから座標を同じにしたら、完了です。

### Voice Clipとクライアントの距離に応じて音量を調節する。
WebGLは標準のSpatial Soundに対応していないため、自前で音量をいじる必要があります。

クライアントのみにアタッチしているコンポーネント（ここではVoice Listener)との距離を計算して、うまいこと計算します。

# 実装！

### 新たに作成するスクリプト

プレイヤーオブジェクトにアタッチ。（クライアントも非クライアントも有効）
```c#:VoiceSpeaker.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using FrostweepGames.VoicePro;
using Photon.Pun;
using Photon.Realtime;

//話し手
//このオブジェクトのもとへVoiceClipが来る。
//クライアントはClientInit()をすること
public class VoiceSpeaker : MonoBehaviourPunCallbacks
{
    public string networkID;

    //クライアントは自身のネットワークIDを取得
    public void ClientInit()
    {
        networkID = NetworkRouter.userID;

        photonView.RPC(nameof(FetchNetworkID), RpcTarget.All, networkID);
    }

    //他人が入室したときにネットワークIDを伝達
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        base.OnPlayerEnteredRoom(newPlayer);

        photonView.RPC(nameof(FetchNetworkID), RpcTarget.All, networkID);
    }

    //ネットワークIDを受信
    [PunRPC]
    private void FetchNetworkID(string id)
    {
        networkID = id;
    }
}
```

これをVoiceClipにアタッチする処理は後述のスクリプト編集で行う。
```c#:VoiceClip.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//音声データオブジェクト(AudioClip)
//対応するプレイヤーオブジェクトのもとへもっていく
//Speaker.csの_selfObject生成時(l.73)に本コンポーネントを付着させること
public class VoiceClip : MonoBehaviour
{
    public string networkID;

    //Allocate完了済みか
    private bool isAllocated = false;

    //Listener発見済みか
    private bool hasFoundListener = false;

    private VoiceListener voiceListener = null;

    //trueならこのコンポーネントで音量調節（WebGLなら必要）
    [SerializeField] private bool shouldEditVolume = true;

    private AudioSource audioSource;

    [Header("Inverse Func")]
    [Tooltip("power")]
    [SerializeField] private int isPower = 2;
    [Tooltip("Expand coef of Y (increase output of func)")]
    [SerializeField] private float isY = 100f;
    [Tooltip("Expand coef of distance")]
    [SerializeField] private float isD = 1f;

    private void Update()
    {
        //Allocate未完なら実行
        if (!isAllocated)
        {
            Allocate();
        }

        //Listener未発見なら探す
        if (!hasFoundListener)
        {
            FindListener();
        }

        if (shouldEditVolume)
        {
            EditVolume();
        }
    }

    public void Init()
    {
        //本Clipが対応するネットワークIDを取得
        //改変しない限りはオブジェクト名が「User_(ネットワークID)」となっている
        //ことを利用している
        networkID = name.Split("_")[1];

        audioSource = GetComponent<AudioSource>();
    }

    private void EditVolume()
    {
        float distance = Vector3.Distance(transform.position, voiceListener.transform.position);

        //音量関数を変えるならここ
        float volume = InversePow(distance);

        //音量の最大値は1
        if(volume > 1f)
        {
            volume = 1f;
        }

        audioSource.volume = volume;
    }

    //逆二乗
    private float InversePow(float d)
    {
        return Mathf.Pow(1f / (d*isD), isPower) * isY;
    }

    //対応するプレイヤーをparentにする
    private void Allocate()
    {
        //全VoiceSpeakerからネットワーク一致するものを探す
        VoiceSpeaker[] voiceSpeakers = FindObjectsOfType<VoiceSpeaker>();
        VoiceSpeaker targetSpeaker = null;
        foreach(VoiceSpeaker speaker in voiceSpeakers)
        {
            if(networkID == speaker.networkID)
            {
                targetSpeaker = speaker;
            }
        }

        //該当者がいない場合はAllocate未完のままreturn
        if (targetSpeaker == null)
        {
            return;
        }

        //本オブジェクトをtargetSpeakerに連結
        transform.parent = targetSpeaker.transform;
        transform.localPosition = Vector3.zero;     //座標を一致させる

        //完了登録
        isAllocated = true;
    }

    private void FindListener()
    {
        //Listenerを検索
        voiceListener = FindObjectOfType<VoiceListener>();

        //もしListenerがいなかったら未完のままにする
        if(voiceListener != null)
        {
            hasFoundListener = true;
        }
    }
}

```

ダミー；クライアントのプレイヤーオブジェクトのみにアタッチ
```C#:VoiceListener.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class VoiceListener : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

```

### 編集するVoice Proのスクリプト

```diff_c_sharp:Assets/FrostweepGames/VoicePro/Scrips/Speaker.cs
//70行目あたり
public Speaker(INetworkActor networkActor, GameObject parent)
{
    NetworkActor = networkActor;

    _selfObject = new GameObject(Name);
    _source = _selfObject.AddComponent<AudioSource>();

//Voice Clipにスクリプトをアタッチ
+   VoiceClip voiceClip = _selfObject.AddComponent<VoiceClip>();
+   voiceClip.Init();

    _hasBeenDisposed = false;
    _buffer = new Buffer();

    SetObjectOwner(parent);
    ApplyConfig(GeneralConfig.Config.speakerConfig);
    InitSound();

    IsActive = true;         
}
```

```diff_c_sharp:Assets/FrostweepGames/VoicePro/Scrips/Networking/NetworkRouter.cs
namespace FrostweepGames.VoicePro
{
	public class NetworkRouter
	{
		private const string Unknown = "Unknown";

		/// <summary>
		/// Network event handler that raises when network data recieved
		/// </summary>
		public event Action<INetworkActor, byte[]> NetworkDataReceivedEvent;

		private static NetworkRouter _Instance;

		//network ID
+		public static string userID;

//////////////////////////////中略

static NetworkRouter()
{
    string id = GetUniqueUserId();
+    userID = id;	//save network ID
    Instance.Register(new NetworkActorInfo()
    {
        id = id,
        name = $"User_{id}"
    });
}
```

下記スクリプトは人によって違います。PhotonNetwork.Instantiate()をする箇所に書き加えてください。
```diff_c_sharp:PUNManager.cs
public override void OnJoinedRoom()
{
    //キャラクターを生成
    GameObject player = PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity, 0);

+    player.GetComponent<VoiceSpeaker>().ClientInit();
+    player.AddComponent<VoiceListener>();
}
```

これで、完了！