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

相关推荐
stevenzqzq6 小时前
android中dp和px的关系
android
一一Null9 小时前
Token安全存储的几种方式
android·java·安全·android studio
JarvanMo9 小时前
flutter工程化之动态配置
android·flutter·ios
时光少年12 小时前
Android 副屏录制方案
android·前端
时光少年12 小时前
Android 局域网NIO案例实践
android·前端
alexhilton12 小时前
Jetpack Compose的性能优化建议
android·kotlin·android jetpack
流浪汉kylin12 小时前
Android TextView SpannableString 如何插入自定义View
android
火柴就是我14 小时前
git rebase -i,执行 squash 操作 进行提交合并
android
你说你说你来说14 小时前
安卓广播接收器(Broadcast Receiver)的介绍与使用
android·笔记
你说你说你来说14 小时前
安卓Content Provider介绍及使用
android·笔记