一、项目背景与技术架构
在智能车载或 AR/VR 场景中,语音交互与角色口型同步是提升用户体验的关键技术。本文将详细介绍如何通过思必驰云端 TTS 接口生成 MP3 音频流,在 Android 端完成格式转换,并将 PCM 数据传递至 Unity 进行音频播放与口型驱动。
技术流程
- 文本转语音:调用思必驰云端 TTS 接口,将输入文本转换为 MP3 字节流。
- 格式转换:在 Android 端将 MP3 流解码为 PCM 格式(先使用 FFmpeg 后改用 MediaCodec)。
- 数据传递:通过 Android 与 Unity 的通信机制,将 PCM 数据传递至 Unity。
- 音频播放与口型同步:Unity 为 PCM 数据添加 WAV 头,播放音频并驱动角色口型。
核心挑战
- 格式转换稳定性:FFmpeg 在 ARM64 架构下出现互斥锁错误,需改用 Android 原生 MediaCodec。
- 实时性要求:从文本到音频播放的全流程需控制在 500ms 内。
- 内存管理:避免大内存块传递导致的性能问题。
二、Android 端实现
1. 思必驰 TTS 接口调用
步骤 1:申请 API Key
- 访问思必驰 DUI 开放平台(dui.ai)创建应用,获取
appKey
和appSecret
。
步骤 2:发送 HTTP 请求
java
try {
ContextInfo context = new ContextInfo(getProductId(), "123465346346", getDeviceName());
AudioInfo audio = new AudioInfo("mp3", 24000);
TtsInfo tts = new TtsInfo(text, "text", "yt_lb", "1.0", 100);
String requestId = getRequestId();
RequestInfo requestInfo = new RequestInfo(requestId, audio, tts);
TtsRequest ttsRequest = new TtsRequest(context, requestInfo);
String callUrl = "openapi/llm/tts";
ApiRequestHelper.sendAsynPostRequest(callUrl, ttsRequest, new ApiRequestHelper.ApiCallback<Response>() {
@SuppressLint("CheckResult")
@Override
public void onSuccess(Response response) {
try {
ResponseBody responseBody = (ResponseBody) response.body();
// 处理成功响应数据,发送给unity
TtsToWavHelper.getInstance(mContext).convertMp3StreamToWav(responseBody.byteStream())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(bytes -> {
// 添加空指针检查
if (bytes == null || bytes.length == 0) {
return;
}
//byte数组保存下来,通知unity来取
mAudioCache.put(requestId, bytes);
UnityMessageHelper.getInstance().sendAudio(requestId, false);
}, error -> Log.d(TAG, "文件转换失败" + error.toString()));
Log.d(TAG, "请求成功,音频已交给unity");
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onError(int code, String message) {
// 处理错误响应
Log.e(TAG, "请求错误 URL: " + callUrl + "\n请求失败,错误码: " + code + "\nSZR-请求失败,错误信息: " + message);
}
@Override
public void onFailure(Throwable t) {
// 处理请求失败
Log.e(TAG, "请求失败 URL: " + callUrl + "Failure: " + t.getMessage());
}
});
} catch (Exception e) {
Log.e(TAG, "请求异常", e);
}
2. MP3 转 PCM 格式
方案 1:FFmpeg 实现(问题版本)
java
private Single<File> executeFfmpegCommand(File inputFile) {
return Single.create(emitter -> {
File outputFile = new File(context.getCacheDir(), "output.pcm");
String[] command = new String[]{
"-y",
"-i", inputFile.getAbsolutePath(),
"-ar", String.valueOf(sampleRate),
"-ac", "1",
//"-acodec", "pcm_s16le", // 改为16位整数格式
"-f", "s16le",
outputFile.getAbsolutePath()
};
FFmpeg.executeAsync(command, (executionId, returnCode) -> {
if (returnCode == 0) {
emitter.onSuccess(outputFile);
} else {
emitter.onError(new RuntimeException("FFmpeg failed with code: " + returnCode));
}
});
});
}
错误分析:
vbnet
FORTIFY: pthread_mutex_lock called on a destroyed mutex (0x66a1380)
- 问题原因 :FFmpeg 在 ARM64 架构下与系统库
libtcb.so
的互斥锁管理冲突。 - 解决方案:改用 Android 原生 MediaCodec。
方案 2:MediaCodec 实现(优化版本)
java
private byte[] decodeWithMediaCodec(File mp3File) throws Exception {
MediaExtractor extractor = new MediaExtractor();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
// 配置MediaExtractor
extractor.setDataSource(mp3File.getAbsolutePath());
int audioTrackIndex = selectAudioTrack(extractor);
MediaFormat format = extractor.getTrackFormat(audioTrackIndex);
// 解码器的配置
MediaCodec decoder = MediaCodec.createDecoderByType(
format.getString(MediaFormat.KEY_MIME));
decoder.configure(format, null, null, 0);
decoder.start();
// 解码
ByteBuffer[] inputBuffers = decoder.getInputBuffers();
ByteBuffer[] outputBuffers = decoder.getOutputBuffers();
// 用于跟踪输入和输出缓冲区的状态
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
extractor.selectTrack(audioTrackIndex);
// 开启循环写入,持续解码直到sawOutputEOS为true
while (!sawOutputEOS) {
if (!sawInputEOS) {
//设置超时时间为5000毫秒
int inputBufferIndex = decoder.dequeueInputBuffer(5000);
if (inputBufferIndex >= 0) {
ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
int sampleSize = extractor.readSampleData(inputBuffer, 0);
//如果sampleSize小于0,说明已经读取到了文件末尾,需要将sawInputEOS设置为true
if (sampleSize < 0) {
decoder.queueInputBuffer(
inputBufferIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
sawInputEOS = true;
} else {
//如果sampleSize大于0,说明有数据可以写入解码器
long presentationTimeUs = extractor.getSampleTime();
decoder.queueInputBuffer(
inputBufferIndex, 0, sampleSize, presentationTimeUs, 0);
extractor.advance();
}
}
}
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
//设置超时时间为5000毫秒
int outputBufferIndex = decoder.dequeueOutputBuffer(bufferInfo, 5000);
if (outputBufferIndex >= 0) {
// 处理解码后的音频数据
ByteBuffer outputBuffer = outputBuffers[outputBufferIndex];
byte[] chunk = new byte[bufferInfo.size];
outputBuffer.get(chunk);
outputStream.write(chunk);
decoder.releaseOutputBuffer(outputBufferIndex, false);
//看一下是否是最后的数据,如果是就退出循环
if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
}
}
// 释放解码器资源
decoder.stop();
decoder.release();
} finally {
// 保障MediaExtractor资源一定被释放
extractor.release();
}
return outputStream.toByteArray();
}
3. 传递 PCM 数据至 Unity
java
// 使用UnitySendMessage传递数据
String unityObjectName = "AudioManager";
String methodName = "OnPcmDataReceived";
String message = Base64.encodeToString(pcmData, Base64.DEFAULT);
UnityPlayer.UnitySendMessage(unityObjectName, methodName, message);
三、Unity 端实现
1. 接收 PCM 数据
csharp
public class AudioManager : MonoBehaviour
{
public void OnPcmDataReceived(string base64Data)
{
byte[] pcmData = Convert.FromBase64String(base64Data);
// 处理PCM数据
}
}
2. 添加 WAV 头
csharp
private byte[] AddWavHeader(byte[] pcmData, int sampleRate = 16000, int bitsPerSample = 16, int channels = 1)
{
using (MemoryStream stream = new MemoryStream())
using (BinaryWriter writer = new BinaryWriter(stream))
{
// RIFF头
writer.Write(Encoding.ASCII.GetBytes("RIFF"));
writer.Write((int)(stream.Length + pcmData.Length + 36 - 8)); // 文件总大小
writer.Write(Encoding.ASCII.GetBytes("WAVE"));
// fmt子块
writer.Write(Encoding.ASCII.GetBytes("fmt "));
writer.Write(16); // 子块大小
writer.Write((ushort)1); // 音频格式(PCM)
writer.Write((ushort)channels);
writer.Write(sampleRate);
writer.Write(sampleRate * channels * bitsPerSample / 8);
writer.Write((ushort)(channels * bitsPerSample / 8));
writer.Write((ushort)bitsPerSample);
// data子块
writer.Write(Encoding.ASCII.GetBytes("data"));
writer.Write(pcmData.Length);
writer.Write(pcmData);
return stream.ToArray();
}
}
3. 播放音频并驱动口型
csharp
private void PlayAudio(byte[] wavData)
{
// 加载音频数据
AudioClip audioClip = AudioClip.Create(
"GeneratedAudio",
wavData.Length / 2, // 采样数
1, // 声道数
16000, // 采样率
false
);
float[] samples = new float[wavData.Length / 2];
Buffer.BlockCopy(wavData, 44, samples, 0, wavData.Length - 44);
audioClip.SetData(samples, 0);
// 播放音频
AudioSource audioSource = gameObject.AddComponent<AudioSource>();
audioSource.clip = audioClip;
audioSource.Play();
// 驱动口型(示例)
StartCoroutine(DriveLipSync(audioSource));
}
private IEnumerator DriveLipSync(AudioSource audioSource)
{
while (audioSource.isPlaying)
{
float[] samples = new float[1024];
audioSource.GetOutputData(samples, 0);
// 计算音频能量
float energy = samples.Sum(s => s * s) / samples.Length;
// 驱动口型动画
float mouthOpenness = Mathf.Clamp(energy * 1000, 0, 1);
lipSyncAnimator.SetFloat("MouthOpenness", mouthOpenness);
yield return null;
}
}
四、错误分析与优化
1. FFmpeg 互斥锁错误
错误日志:
vbnet
FORTIFY: pthread_mutex_lock called on a destroyed mutex (0x66a1380)
- 原因:FFmpeg 在多线程环境下未正确管理互斥锁,导致锁被销毁后仍被访问。
- 解决方案:改用 Android 原生 MediaCodec,避免第三方库的线程管理问题。
2. 性能优化
- 内存复用 :使用
ByteBuffer
的直接内存减少 GC 压力。 - 异步处理 :在 Android 端使用
HandlerThread
处理音频解码,避免阻塞主线程。 - 数据压缩 :通过 Base64 编码传递数据时,可考虑使用
ZLIB
压缩降低传输量。
五、总结
本文详细介绍了 Android 与 Unity 集成实现思必驰 TTS 音频流处理及口型同步的完整流程,重点解决了 FFmpeg 在 ARM64 架构下的兼容性问题,并通过安卓原生 MediaCodec 实现了稳定高效的格式转换(实际转换效率提高百分之30)。