Unity 音频开发


如果你玩过很多游戏或使用过多个应用程序,就一定会发现,游戏和应用程序中的音效可以为用户提供更沉浸的体验。如果一个应用程序没有音频,沉浸感就少了很多。

接下来我们就游戏领域来具体谈一谈这个问题。声音是人类生活不可或缺的一部分,视频游戏同理,我们通常称视频游戏里的声音为“游戏音效”。游戏中的音频包括声音效果、环境声音、背景音乐,以及游戏玩家的声音。我们可以利用游戏工具(例如 unity、unreal 等)在应用程序中开发音频仿真功能。

开发完成之后呢?测试!!!! 那么,有没有可以帮助我们实现 XR 空间音频共享等功能的自动化工具或软件?

免责声明:本文是针对一个应用程序的案例研究,我们该应用程序中有化身,他们能够相互交谈,就像通过实时音/视频应用程序(比如 zoom)交谈一样, 唯一的区别是,该应用程序是一个在 unity 中构建的桌面 XR 应用程序。我们将在这个 3D 应用程序中测试音频共享功能。

前期准备:要有 unity、C# 和使用 Arium 测试自动化的基本知识。


技术规格要求:

  • 语言:C#
  • 游戏开发工具:Unity
  • 集成提供音频共享功能的 SDK:声网引擎视频 SDK
  • 测试框架:Arium
  • 应用类型:桌面
  • 操作系统:Mac OS


功能介绍:

我们要构建的是一个建立在 unity 内的 Mac 版 XR 桌面应用程序,该应用程序有声网视频 SDK 提供的音频共享功能。每个加入其特定频道的用户都有化身,该化身可以通过静音/取消静音按钮相互交流。当一个用户取消静音并开始说话时,聊天室里的其他人都可以听到该用户的声音。


步骤

  1. 登录应用程序
  2. 进入主聊天室
  3. 连接本地主机/客户端 IP
  4. 用户默认处于静音状态,所以需要取消静音
  5. 随便说一些内容。
  6. 聊天室里的其他人应该能够听到
探索测试很好!但是自动化呢?如何通过自动化来复制这个场景?


自动化测试方法

接下来我们详述将这个方案自动化的方法。

关于 XR 自动化,我们没有能提供所有自动化功能的特定工具。常用的方法都要求开发者对开发代码有一定的理解。要了解开发,可以参考下列链接:

https://api-ref.agora.io/en/video- sdk/unity/3.x/index.html

在探讨自动化方法之前,我们先讨论一下要验证什么!

  • 验证当一个用户处于静音状态时,聊天室里的其他用户听不到该用户的声音。
  • 验证当一个用户取消静音时,聊天室里的其他用户能听到该用户的声音。
  • 验证声网视频 sdk 是否与应用程序正确集成。
  • 验证当用户点击静音按钮时,静音图标变为取消静音图标。
  • 验证当用户点击取消静音按钮时,取消静音的图标变为静音图标。
本文只探讨前两个验证,因为后三个验证可以通过使用 Arium 和屏幕共享测试方法轻松实现自动化。具体可参考: https://medium.com/xrpractices/getting-started-with-3d-automation-arium-at-glance-fca27273426d
https://medium.com/xrpractices/screen-share-test-in-unity-59803935f1e3

测试方法 1:同时使用 AudioSource 和 AudioListener

这是我们想到的第一个方法。

1. 我们创建了两个空的 gameobject。

2. 在其中一个中添加了 audiosource 组件并把游戏对象重命名为 microphonePrefab。

3. 然后是 audiosource 组件的 audioclip 子组件。如果我们在这个 audioclip 子组件上附加任何音频片段,该音频就会在运行时播放。但如果我们保持该字段为空,它就会接受麦克风的输入。

4. 所以我们保持 audioclip 为空,并将这些 audiosource 和 audiolistener 游戏对象转换为 prefabs,在 Arium 框架的 Arium.cs 文件中加入以下代码,就可以在运行时实例化。

public GameObject InstantiatePrefab(GameObject gameobject, Vector3 position)
        {
            GameObject go = GameObject.Instantiate(gameobject, position, Quaternion.identity);
            return go;

        }

5. 在将这个 microphonePrefab gameobject 转换为 prefab 之前,我们在该 gameobject 附加了下列脚本。

using UnityEngine;
public class VoiceScript1 : MonoBehaviour
{
    public AudioSource audioSource;

    void Awake()
    { 
        audioSource = GetComponent<AudioSource>();
        audioSource.clip = Microphone.Start("", true, 5, 44100);
        audioSource.loop = true;
        while (!(Microphone.GetPosition(null) > 0)){}
        audioSource.Play();
      
    }
}

这个 voicescript1 有一个 AudioSource 音频源变量,所以我们要在把它转换为 prefab 之前,把 microphonePrefab 本身的 audiosource 组件拖放到这个公共变量中。

6. 接下来我们写了一个脚本来播放音频,就像麦克风输入一样。这是用“进程类”和 “afplay” 终端命令实现的。

using System.Diagnostics;

public class PlayAudio 
{
    public void PlayAudioFile()
    {   
        var p = new Process
        {
            StartInfo =
            {   
                
                FileName = "afplay",
             
                Arguments = "Assets/Tests/why-hello-there-103596.mp3"
                
            }
            
        }.Start();
       
    }
}

7. 我们还写了一个脚本来获取 audiolistener 的数据,这个数据可以帮助我们计算音频强度。

using UnityEngine;
public class audioData : MonoBehaviour
{
    public int qSamples = 4096; 
  
    private float[] samples;

    private float clipLoudness = 0f;
  
    public void Start () 
    {
        samples = new float[qSamples]; 
    }
    
    public float GetRMS(int channel )
    {
        AudioListener.GetOutputData(samples, channel
        float sum = 0; 
        for (int i=0; i < qSamples; i++)
        {   
            Debug.Log("Sample:  " + i + "::" +samples[i]);
            sum += samples[i]*samples[i];
            // sum squared samples 
        } 
        foreach (var sample in samples) {
            clipLoudness += Mathf.Abs(sample);
        }
        
        return Mathf.Sqrt(sum/qSamples); 
    
    }


}

局限性

因为 RMS 的结果不同,所以测试出现偏差。

预期结果:当我们取消静音并播放音频时,输出的 RMS 应该大于 0;当我们处于静音状态并播放音频时,RMS 应该等于 0。

实际结果:无论静音或不静音,测试获得的 RMS 均大于 0。

测试失败的原因

AudioSource 在游戏场景中,从麦克风直接输入并在游戏场景中播放音频。Audiolistener 不是在听麦克风,而是在听 audiosource。因此,当处于静音状态并播放音频时,audiosource 仍然会接受麦克风的输入,Audiolistener 就会得到输入,导致 均方根大于 0。


测试方法 2:不用 AudioSource,只使用 AudioListener

这个方法从麦克风直接输入到 audiolistener。

测试脚本:

//Loading the home scene where we have audio sharing feature
LoadHomeScene();
yield return new WaitForSeconds(15);
//connect to localhost/client ip using network manager
GameObject networkManager = _findGameObjects.FindNetworkManager();
networkManager.GetComponent<NetworkManager>().StartHost();
yield return new WaitForSeconds(10);
//click on microphone button to unmute
GameObject microphone = _findGameObjects.FindMicButton();
_arium.PerformAction(new UnityPointerClick(), microphone);
yield return new WaitForSeconds(4);
//play the audio which will act like microphone input
PlayAudio audio = new PlayAudio();
audio.PlayAudioFile();
yield return new WaitForSeconds(4);
//calling audioData script to calculate RMS value
audioData listen = _arium.GetComponent<audioData>("LoadAvator [connId=0]/Avatar");
//here 0 inside GetRMS denotes channel 
float vol = listen.GetRMS(0);
Debug.Log("volume ====" + vol);

输出:

每次我们都会得到 RMS=0

局限性:

AudioListener 不能直接监听麦克风。

测试失败的原因:

场景中的 audiolistener 只能获得 audiosource 输入,不能直接获得麦克风输入。因此,每当我们播放音频并试图从 audiolistener 获取数据时,就会得到 RMS =0。


测试方法 3:通过语音识别进行验证

我们测试的第三个方法是基于语音识别。我们在大量的研究后发现了这段代码。这个方法的主要想法是,创建一个游戏对象(例如,立方体),并在运行时在主场景中将该对象实例化,然后在该脚本中创建一个字典,其中有一组立方体可以识别的关键词。然后,我们通过自动化给其中一些关键词音频输入,也可以说是通过自动化播放这些关键词的音频。 针对每个关键词,我们都有一个附加给立方体的动作。我们将在游戏场景中通过测试脚本在运行时调用这个立方体,然后播放音频,立方体识别关键词并在场景中执行相应动作。这意味着音频是在游戏场景内进行的,因此聊天室的所有其他人也能听到正在发送的音频。

using UnityEngine;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Collections;
using UnityEngine.Windows.Speech;
public class VoiceControl : MonoBehaviour
{
    // Start is called before the first frame update
    // Voice command vars
    private Dictionary<string, Action> keyActs = new Dictionary<string, Action>();
    private KeywordRecognizer recognizer;
    // Var needed for color manipulation
    private MeshRenderer cubeRend;
    //Var needed for spin manipulation
    private bool spinningRight;
    //Vars needed for sound playback.
    private AudioSource soundSource;
    public AudioClip[] sounds;
    void Start()
    {
        cubeRend = GetComponent<MeshRenderer>();
        soundSource = GetComponent<AudioSource>();
        //Voice commands for changing color
        keyActs.Add("red", Red);
        keyActs.Add("green", Green);
        keyActs.Add("blue", Blue);
        keyActs.Add("white", White);
        //Voice commands for spinning
        keyActs.Add("spin right", SpinRight);
        keyActs.Add("spin left", SpinLeft);
        //Voice commands for playing sound
        keyActs.Add("please say something", Talk);
        //Voice command to show how complex it can get.
        keyActs.Add("pizza is a wonderful food that makes the world better", FactAcknowledgement);
        recognizer = new KeywordRecognizer(keyActs.Keys.ToArray());
        recognizer.OnPhraseRecognized += OnKeywordsRecognized;
        recognizer.Start();
        
    }
    
    void OnKeywordsRecognized(PhraseRecognizedEventArgs args)
    {
        Debug.Log("Command: " + args.text);
        keyActs[args.text].Invoke();
    }
    
    void Red()
    {
        cubeRend.material.SetColor("_Color", Color.red);
    }
    void Green()
    {
        cubeRend.material.SetColor("_Color", Color.green);
    }
    void Blue()
    {
        cubeRend.material.SetColor("_Color", Color.blue);
    }
    void White()
    {
        cubeRend.material.SetColor("_Color", Color.white);
    }
    
    void SpinRight()
    {
        spinningRight = true;
        StartCoroutine(RotateObject(1f));
    }
    void SpinLeft()
    {
        spinningRight = false;
        StartCoroutine(RotateObject(1f));
    }
    private IEnumerator RotateObject(float duration)
    {
        float startRot = transform.eulerAngles.x;
        float endRot;
        if (spinningRight)
            endRot = startRot - 360f;
        else
            endRot = startRot + 360f;
        float t = 0f;
        float yRot;
        while (t < duration)
        {
            t += Time.deltaTime;
            yRot = Mathf.Lerp(startRot, endRot, t / duration) % 360.0f;
            transform.eulerAngles = new Vector3(transform.eulerAngles.x, yRot, transform.eulerAngles.z);
            yield return null;
        }
    }
    void Talk()
    {
        soundSource.clip = sounds[UnityEngine.Random.Range(0, sounds.Length)];
        soundSource.Play();
    }
    void FactAcknowledgement()
    {
        Debug.Log("How right you are.");
    }
    
}

局限性:

我们必须在基于 Mac 的应用程序上进行此测试,但是代码只适用于 windows 系统。它使用的是 UnityEngine.Windows.Speech 库。我们找不到任何基于 mac 的应用程序的类似的库。虽然我们没有在 Mac 系统上进行测试,但我们证明了它在 windows 系统上可以正常工作。


测试方法 4:录制游戏音频

本方法计划使用 ffmpeg 命令来录制游戏音频。我们计划先通过脚本播放音频,然后录制游戏音频,再将 .mp3 或 .wav 文件转换为文本,即字符串,然后可以对给定的输入进行验证。

using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;

public class RecordAudio 
{
    public void RecordAudioFile()
    {   
        var p = new Process
        {
            StartInfo =
            {   
                
                FileName = "ffmpeg",
                Arguments = "/Users/alisha.raizada/Music/audiofilecapture.mp3"
                
            }
            
        }.Start();
       
    }
}

局限性:

此方法无法实现预期目的。

预期结果:记录一个特定的麦克风频道。

实际结果:只实现了正常的音频录制。

实施这个方法后,我们发现需要获取特定的 AudioFrame 的程序,这样才能实现预期目的,而且这种功能只能由声网引擎来提供。这就是我们一开始提到的问题,在 XR 自动化中,更好地理解开发代码和实现是非常重要的,这为我们打开了很多学习和探索的大门。


测试方法 5:从特定的 AudioFrame 获取音频数据

使用这个方法,我们需要添加一个内部类“AudioFrameObserver”,该类包含了从加入特定频道到离开特定频道的所有方法。

在“Join”函数中,添加下列几行代码:

另外,我们需要创建一个名为“GetAudioFrame”的单独函数,将其返回音频帧。

internal class AudioFrameObserver : IAudioFrameObserver

{
   private readonly SpatialAudio _videoSample;
   internal AudioFrameObserver(SpatialAudio videoSample)
   {
       _videoSample = videoSample;
   }

   // Sets whether to receive remote video data in multiple channels.
   public virtual bool IsMultipleChannelFrameWanted()
   {
       return true;
   }

   // Occurs each time the player receives an audio frame.
   public bool OnFrame(AudioPcmFrame videoFrame)

   {
       return true;
   }
   // Retrieves the mixed captured and playback audio frame.
  public override bool OnMixedAudioFrame(string channelId, AudioFrame audioFrame)

   {
       return true;
   }

   // Gets the audio frame for playback.
   public override bool OnPlaybackAudioFrame(string channelId, AudioFrame audioFrame)

   {
       return true;
   }

   // Retrieves the audio frame of a specified user before mixing.
   public override bool OnPlaybackAudioFrameBeforeMixing(string channelId, uint uid, AudioFrame audioFrame)

   {
       return true;
   }
   // Gets the playback audio frame before mixing from multiple channels.

   public virtual bool OnPlaybackAudioFrameBeforeMixingEx(string channelId, uint uid, AudioFrame audioFrame)

   {
       return false;
   }

   // Gets the captured audio frame.

   public override bool OnRecordAudioFrame(string channelId, AudioFrame audioFrame)

   {  
       _videoSample._audioFrame = audioFrame;
       return true;

   }}
On join 

public void Join(string _token , string _channelName)
{  
   
  
   RtcEngine.RegisterAudioFrameObserver(new AudioFrameObserver(this));
  
   // Set the format of the captured raw audio data.
   int SAMPLE_RATE = 16000, SAMPLE_NUM_OF_CHANNEL = 1, SAMPLES_PER_CALL = 1024;
   RtcEngine.SetRecordingAudioFrameParameters(SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL,
   RAW_AUDIO_FRAME_OP_MODE_TYPE.RAW_AUDIO_FRAME_OP_MODE_READ_WRITE, SAMPLES_PER_CALL);
   RtcEngine.SetPlaybackAudioFrameParameters(SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL,
   RAW_AUDIO_FRAME_OP_MODE_TYPE.RAW_AUDIO_FRAME_OP_MODE_READ_WRITE, SAMPLES_PER_CALL);
   RtcEngine.SetMixedAudioFrameParameters(SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL, SAMPLES_PER_CALL);
}
//test func
public AudioFrame GetAudioFrame()
{
    Debug.Log("Bytes:::"+ _audioFrame);
    return _audioFrame;
}

完成以上步骤后,转到主测试文件,调用“GetAudioFrame”方法。

SpatialAudio obj = new SpatialAudio();
obj.GetAudioFrame();

然后,从上述选项中选择一种方法来获取特定音频帧的数据。

注意:从不同的文件夹或目录中调用特定方法时,需要在程序集定义中提供该方法的引用。

以上就是所有内容,感谢大家的阅读!



原文作者:Alisha Raizada
原文链接:https://medium.com/xrpractices/playing-with-audio-in-unity-453970fdc2ef
推荐阅读
相关专栏
前端与跨平台
90 文章
本专栏仅用于分享音视频相关的技术文章,与其他开发者和声网 研发团队交流、分享行业前沿技术、资讯。发帖前,请参考「社区发帖指南」,方便您更好的展示所发表的文章和内容。