Android 与 Unity 集成实现,思必驰 TTS 音频流处理及口型同步

一、项目背景与技术架构

在智能车载或 AR/VR 场景中,语音交互与角色口型同步是提升用户体验的关键技术。本文将详细介绍如何通过思必驰云端 TTS 接口生成 MP3 音频流,在 Android 端完成格式转换,并将 PCM 数据传递至 Unity 进行音频播放与口型驱动。

技术流程

  1. 文本转语音:调用思必驰云端 TTS 接口,将输入文本转换为 MP3 字节流。
  2. 格式转换:在 Android 端将 MP3 流解码为 PCM 格式(先使用 FFmpeg 后改用 MediaCodec)。
  3. 数据传递:通过 Android 与 Unity 的通信机制,将 PCM 数据传递至 Unity。
  4. 音频播放与口型同步:Unity 为 PCM 数据添加 WAV 头,播放音频并驱动角色口型。

核心挑战

  • 格式转换稳定性:FFmpeg 在 ARM64 架构下出现互斥锁错误,需改用 Android 原生 MediaCodec。
  • 实时性要求:从文本到音频播放的全流程需控制在 500ms 内。
  • 内存管理:避免大内存块传递导致的性能问题。

二、Android 端实现

1. 思必驰 TTS 接口调用

步骤 1:申请 API Key

  • 访问思必驰 DUI 开放平台(dui.ai)创建应用,获取appKeyappSecret

步骤 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)。

相关推荐
哲科软件3 小时前
跨平台开发的抉择:Flutter vs 原生安卓(Kotlin)的优劣对比与选型建议
android·flutter·kotlin
jyan_敬言9 小时前
【C++】string类(二)相关接口介绍及其使用
android·开发语言·c++·青少年编程·visual studio
程序员老刘10 小时前
Android 16开发者全解读
android·flutter·客户端
福柯柯11 小时前
Android ContentProvider的使用
android·contenprovider
不想迷路的小男孩11 小时前
Android Studio 中Palette跟Component Tree面板消失怎么恢复正常
android·ide·android studio
餐桌上的王子11 小时前
Android 构建可管理生命周期的应用(一)
android
菠萝加点糖11 小时前
Android Camera2 + OpenGL离屏渲染示例
android·opengl·camera
用户20187928316711 小时前
🌟 童话:四大Context徽章诞生记
android
yzpyzp11 小时前
Android studio在点击运行按钮时执行过程中输出的compileDebugKotlin 这个任务是由gradle执行的吗
android·gradle·android studio
aningxiaoxixi11 小时前
安卓之service
android