Unity,微软Azure文字转语音,流式AudioClip处理

本文介绍了如何在Unity中使用Azure的语音合成服务时优化音频流式获取,避免播放中断。通过创建流式AudioClip并配合正确处理音频数据,确保音频连续播放,即使在网络不稳定时也能改善用户体验。
摘要由CSDN通过智能技术生成

我先看了别人的文章:

Unity 工具 之 Azure 微软语音合成普通方式和流式获取音频数据的简单整理

然后发现有些可以优化的地方。

在流式获取语音的时候,声音播放有时候播着播着就没了,原因大概是AudioSource按时间流式播放自己的AudioClip,但是Azure文字转换音频获取数据却因为某些原因(比如网络问题、再比如缓存太大之类的)跟不上AudioSouce的脚步,AudioSource已经播放过了某个时间点,需要的音频数据却还没有填充,声音自然就发不出来了。心想如果使用流式的AudioClip可以优化一下这个过程。

关于流式AudioClip,可以看Unity官方文档与示例代码:

AudioClip.Create

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、资源释放、异常情况处理,等。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值