我先看了别人的文章:
Unity 工具 之 Azure 微软语音合成普通方式和流式获取音频数据的简单整理
然后发现有些可以优化的地方。
在流式获取语音的时候,声音播放有时候播着播着就没了,原因大概是AudioSource按时间流式播放自己的AudioClip,但是Azure文字转换音频获取数据却因为某些原因(比如网络问题、再比如缓存太大之类的)跟不上AudioSouce的脚步,AudioSource已经播放过了某个时间点,需要的音频数据却还没有填充,声音自然就发不出来了。心想如果使用流式的AudioClip可以优化一下这个过程。
关于流式AudioClip,可以看Unity官方文档与示例代码:
Unity官方示例源码如下:
using UnityEngine;
using System.Collections;
public class ExampleClass : MonoBehaviour
{
public int position = 0;
public int samplerate = 44100;
public float frequency = 440;
void Start()
{
AudioClip myClip = AudioClip.Create("MySinusoid", samplerate * 2, 1, samplerate, true, OnAudioRead, OnAudioSetPosition);
AudioSource aud = GetComponent<AudioSource>();
aud.clip = myClip;
aud.Play();
}
void OnAudioRead(float[] data)
{
int count = 0;
while (count < data.Length)
{
data[count] = Mathf.Sin(2 * Mathf.PI * frequency * position / samplerate);
position++;
count++;
}
}
void OnAudioSetPosition(int newPosition)
{
position = newPosition;
}
}
其中,需要注意的几点是:
AudioClip myClip = AudioClip.Create("MySinusoid", samplerate * 2, 1, samplerate, true, OnAudioRead, OnAudioSetPosition);
如上就是创建了一个流式的AudioClip,AudioClip的长度是2秒(其实我们设置1秒就够了),当这个AudioClip需要获取接下来要播放的某一小段音频数据的时候,就会回调执行OnAudioRead方法,我们要做的就是在这个方法中把参数的data数组给填充了。
data数组的大小不是固定的,是OnAudioRead方法回调之前自动分配好的,我们不用分配,data数组都不大,有时最多相当于零点几秒的音频数据,如果我们不填充,则里面的数据值很可能还会保持上一次的数据,所以应该一定填充,哪怕填0。
设置AudioSource是Loop的情况下,AudioSource播放的时候,OnAudioRead就会不断地回调来获取数据。如果AudioSource不是Loop,则AudioSource播放2秒钟到了片段结尾,就会停住,OnAudioRead也不回调了。
我发现,在流式AudioClip刚创建的时候就会回调几次OnAudioRead作为音频的开头数据,即使此时还没有将AudioClip赋值给AudioSource.clip,所以为了减少延迟最好在Azure文字转语音的缓存数据发育一小段时间之后再创建流式AudioClip。
下面是以开头的文章为基础,添加流式AudioClip的我的处理方案:
添加一个AzureTTSStream_AudioClip.cs脚本文件,脚本内容如下:
using UnityEngine;
using Microsoft.CognitiveServices.Speech;
using System.IO;
using System;
using System.Collections;
using System.Threading.Tasks;
public class AzureTTSStream_AudioClip : MonoSingleton<AzureTTSStream_AudioClip>
{
private string m_SubscriptionKey = "此处填入你的key";//你的Key
private string m_Region = "eastasia";
private string m_SpeechSynthesisLanguage = "zh-CN";
private string m_SpeechSynthesisVoiceName = "zh-CN-XiaochenNeural";
public const int m_SampleRate = 16000;//采样率,也就是音频的一秒钟里面有多少个采样点
private Coroutine m_TTSCoroutine;
private AzureTTS_StreamDataHandler m_TTSStreamDataHandler;
private AudioSource m_AudioSource;
private AudioClip m_AudioClip;
/// <summary>
/// 设置音源
/// </summary>
/// <param name="audioSource"></param>
public void SetAudioSource(AudioSource audioSource)
{
m_AudioSource = audioSource;
}
/// <summary>
/// 开始TTS
/// </summary>
/// <param name="spkMsg"></param>
/// <param name="audioSource"></param>
/// <param name="errorAction"></param>
public void StartTTS(string spkMsg, AudioSource audioSource, Action<string> errorAction = null)
{
SetAudioSource(audioSource);
StartTTS(spkMsg, errorAction);
}
/// <summary>
/// 开始TTS
/// </summary>
/// <param name="spkMsg"></param>
/// <param name="errorAction"></param>
public void StartTTS(string spkMsg, Action<string> errorAction = null)
{
StopTTS();
m_TTSCoroutine = StartCoroutine(SynthesizeAudioCoroutine(spkMsg, errorAction));
}
/// <summary>
/// 停止TTS
/// </summary>
public void StopTTS()
{
if (m_TTSCoroutine != null)
{
StopCoroutine(m_TTSCoroutine);
m_TTSCoroutine = null;
}
if (m_TTSStreamDataHandler != null)
{
m_TTSStreamDataHandler.Dispose();
m_TTSStreamDataHandler = null;
}
if (m_AudioSource != null)
{
m_AudioSource.Stop();
m_AudioSource.clip = null;
m_AudioSource.loop = false;
m_AudioSource.playOnAwake = false;
}
if (m_AudioClip != null)
{
Destroy(m_AudioClip);
m_AudioClip = null;
}
}
#region 使用流式AudioClip
public IEnumerator SynthesizeAudioCoroutine(string spkMsg, Action<string> errorAction)
{
Debug.Log($"文字转音频,开始");
//创建Config
var config = SpeechConfig.FromSubscription(m_SubscriptionKey, m_Region);
config.SpeechSynthesisLanguage = m_SpeechSynthesisLanguage;
config.SpeechSynthesisVoiceName = m_SpeechSynthesisVoiceName;
m_TTSStreamDataHandler = new AzureTTS_StreamDataHandler(config);
m_TTSStreamDataHandler.StartHandleText(spkMsg);
//此时AudioSource还是停止的状态
while (true)
{
if (!m_AudioSource.isPlaying && m_TTSStreamDataHandler.IsRemainAudioDataToRead())
{
//让AzureTTS_StreamDataHandler先发育一会儿。
yield return new WaitForSecondsRealtime(0.3f);
//这个时候才创建流式AudioClip,为的是让AudioClip一开始就能吃到数据。
//流式AudioClip在创建的时候马上就会要求填充一部分数据,如果在这之前创建流式AudioClip可能就会吃到空数据进行播放,
//就会导致一开始多了一点时间延迟,或者导致开头声音卡一下。
//流式AudioClip,时间长度1秒钟就够了。
m_AudioClip = AudioClip.Create("SynthesizedAudio", m_SampleRate * 1, 1, m_SampleRate, true, (float[] data) =>
{
//一定填充数据
m_TTSStreamDataHandler.ExtractAudioData(data);
});
m_AudioSource.clip = m_AudioClip;
m_AudioSource.loop = true;
m_AudioSource.Play();
}
if (m_TTSStreamDataHandler.handleState == HandleStateEnum.Completed ||
m_TTSStreamDataHandler.handleState == HandleStateEnum.Abort ||
m_TTSStreamDataHandler.handleState == HandleStateEnum.Fail)
{
if (m_TTSStreamDataHandler.IsRemainAudioDataToRead())
{
yield return null;
}
else
{
break;
}
}
else
{
yield return null;
}
}
Debug.Log($"文字转音频,转换结果读取结束,之后等AudioSource播放完");
m_AudioSource.loop = false;//停止循环
//到这里,m_AudioSource虽然不循环了,但是还是会播放到最后,因此AudioClip还会继续回调来获取数据,直到播放结束,
//或者你可以提前调用Stop,但是提前调用Stop可能会导致最后的一点声音突然消失。
//等播放完成释放资源
while (m_AudioSource.isPlaying)//等播放完
{
yield return null;
}
Debug.Log("释放资源");
m_TTSStreamDataHandler.Dispose();
m_TTSStreamDataHandler = null;
m_AudioSource.clip = null;
Destroy(m_AudioClip);
m_AudioClip = null;
}
#endregion
}
#region 将流式处理Azure文本转语音的操作封装起来,里面的数据可以给AudioClip用
public class AzureTTS_StreamDataHandler : IDisposable
{
private SpeechConfig speechConfig;
private SpeechSynthesizer synthesizer;
private AudioDataStream audioDataStream;
/// <summary>
/// 每次同步多少个音频采样
/// </summary>
private int updateSize = 1000;
/// <summary>
/// 包含所有的接收到的数据转换成音频采样的结果
/// </summary>
private float[] audioClipDataCache;
/// <summary>
/// 当前要读取的索引位置
/// </summary>
private int curAudioClipPos = 0;
public HandleStateEnum handleState = HandleStateEnum.None;
public AzureTTS_StreamDataHandler(SpeechConfig config)
{
speechConfig = config;
synthesizer = new SpeechSynthesizer(config, null);
}
public async void StartHandleText(string text)
{
handleState = HandleStateEnum.Handing;
try
{
SpeechSynthesisResult result = await synthesizer.StartSpeakingTextAsync(text);
if (result.Reason == ResultReason.SynthesizingAudioStarted)
{
audioDataStream = AudioDataStream.FromResult(result);
audioClipDataCache = new float[0];
byte[] buffer = new byte[updateSize * 2];//乘以2是因为一个音频采样数据其实是16位?
uint bytesRead = 0;//读取到的字节数量
while (true)
{
await Task.Run(() => { bytesRead = audioDataStream.ReadData(buffer); });
if (bytesRead > 0)
{
if (bytesRead % 2 == 1)
{
//bytesRead必须是偶数。一般获取的bytesRead就是偶数,不会例外。
//但是如果因为网络中断或其他原因导致出错,我就不知道这最后一次还能不能保证就是偶数,因此如果判断到是奇数,就把最后一个字节舍弃。
bytesRead -= 1;
}
MemoryStream memStream = new MemoryStream();
memStream.Write(buffer, 0, (int)bytesRead);
if (memStream.Length > 0)
{
var tempData = memStream.ToArray();
var audioData = new float[tempData.Length / 2];
for (int i = 0; i < audioData.Length; ++i)
{
audioData[i] = (short)(tempData[i * 2 + 1] << 8 | tempData[i * 2]) / 32768.0F;
}
int destinationIndex = audioClipDataCache.Length;
Array.Resize(ref audioClipDataCache, audioClipDataCache.Length + audioData.Length);//这一步之后数组的Length直接就变了
Array.Copy(audioData, 0, audioClipDataCache, destinationIndex, audioData.Length);
}
}
else//读取了0个,表示结束了,TTS流(audioDataStream)里面没有可读取的了(网络同步也结束了,TTS流的缓存数据也都读取完了)
{
StreamStatus streamStatus = audioDataStream.GetStatus();
if (streamStatus == StreamStatus.AllData)
{
handleState = HandleStateEnum.Completed;
Debug.Log($"Azure 文字转音频,完成");
}
else if (streamStatus == StreamStatus.Canceled)
{
//接收过程中出了错,可能网络不好
handleState = HandleStateEnum.Abort;
Debug.Log($"Azure 文字转音频,中断");
}
break;
}
}
}
else if (result.Reason == ResultReason.Canceled)//可能证书不对,也可能网络有问题
{
handleState = HandleStateEnum.Fail;
var cancellation = SpeechSynthesisCancellationDetails.FromResult(result);
string newMessage = $"Azure TTS CANCELED:\nReason=[{cancellation.Reason}]\nErrorDetails=[{cancellation.ErrorDetails}]\nDid you update the subscription info?";
Debug.LogError(newMessage);
}
}
catch (Exception e)
{
handleState = HandleStateEnum.Fail;
Debug.LogError($"Azure TTS 异常,Message={e.Message},StackTrace={e.StackTrace}");
}
}
/// <summary>
/// 还剩多少音频数据可以提取。
/// 我觉得外部在调用ExtractAudioData之前应该先判断缓存里面还剩的数据量够不够填充data,如果不够就不要调用了。
/// 当然也可以不这样做,直接调用ExtractAudioData也没事,看需求。
/// </summary>
/// <returns></returns>
public int RemainAudioDataSize()
{
if (audioClipDataCache != null)
{
return audioClipDataCache.Length - curAudioClipPos;
}
return 0;
}
/// <summary>
/// 是否还有音频数据需要读取
/// </summary>
/// <returns></returns>
public bool IsRemainAudioDataToRead()
{
return RemainAudioDataSize() > 0;
}
/// <summary>
/// 将AudioClip缓存数据从当前位置提取到已经分配好的data数组中,直至把data填满,如果填不满的地方,则填0。
/// 返回值表示是否真的读取到音频数据。
/// </summary>
/// <param name="data"></param>
public bool ExtractAudioData(float[] data)
{
if (data == null || data.Length == 0) return false;
bool hasData = false;//是否真的读取到数据
int dataIndex = 0;//当前要写入的索引位置
if (audioClipDataCache != null && audioClipDataCache.Length > 0)
{
while (curAudioClipPos < audioClipDataCache.Length && dataIndex < data.Length)
{
data[dataIndex] = audioClipDataCache[curAudioClipPos];
curAudioClipPos++;
dataIndex++;
hasData = true;
}
}
//剩余部分填0
while (dataIndex < data.Length)
{
data[dataIndex] = 0;
dataIndex++;
}
return hasData;
}
public void Dispose()
{
if (synthesizer != null)
{
synthesizer.Dispose();
synthesizer = null;
}
if (audioDataStream != null)
{
audioDataStream.Dispose();
audioDataStream = null;
}
}
}
#endregion
public enum HandleStateEnum
{
None,
/// <summary>
/// 处理中
/// </summary>
Handing,
/// <summary>
/// 完成,所有的数据都同步完成
/// </summary>
Completed,
/// <summary>
/// 中断了,数据同步中断了,不会继续同步了
/// </summary>
Abort,
/// <summary>
/// 都没开始同步就失败了,或者出现了异常而失败了
/// </summary>
Fail
}
然后就是像开头文章中一样添加按钮和按钮响应:
using UnityEngine;
using UnityEngine.UI;
public class Test : MonoBehaviour
{
public InputField m_InputField;
public Button m_NormalButton;
public Button m_StreamButton;
public Button m_StreamButton_AudioClip;
public AudioSource m_AudioSource;
// Start is called before the first frame update
void Start()
{
m_NormalButton.onClick.AddListener(() =>
{
AzureTTSNormal.Instance.StartTTS(m_InputField.text, m_AudioSource);
});
m_StreamButton.onClick.AddListener(() =>
{
AzureTTSStream.Instance.StartTTS(m_InputField.text, m_AudioSource);
});
m_StreamButton_AudioClip.onClick.AddListener(() =>
{
AzureTTSStream_AudioClip.Instance.StartTTS(m_InputField.text, m_AudioSource);
});
}
}
这样文字转语音的时候,播放声音就不会播着播着突然听不到声音了,除非网络不好。
上面的代码只是一个示例,如果用于实际项目,需要将其更加完善,比如是否还有其他BUG、资源释放、异常情况处理,等。