深入 Android TTS 语音合成全链路:从 API 桥接到端侧声码器

做车载语音助手时,遇到一个问题:TTS 首字延迟(Time To First Audio)比竞品高了 300ms。排查调用链后发现,瓶颈不在网络,也不在模型推理——引擎服务的绑定过程吃掉 100ms+,而且每次合成都重新走一遍 setup。

Android TTS 框架的设计决定了这个局面:上层统一 API,但引擎选择、绑定、音频合成全部跑在独立进程的 Service 里。不把这套桥接机制吃透,延迟很难压下去。

TextToSpeech 初始化的真实开销

new TextToSpeech(context, listener) 的构造过程做了三件事:

  1. 通过 TtsEngines 查询系统已注册的引擎列表
  2. 根据用户默认选择或 locale 匹配规则确定目标引擎
  3. bindService 跨进程绑定引擎的 TextToSpeechService

核心代码路径在 TextToSpeech.javainitTts() 方法中:

// TextToSpeech 内部初始化链路(简化)
private int initTts(Context ctx, OnInitListener listener) {
    // 1. 获取引擎信息
    mEnginesHelper = new TtsEngines(ctx);
    String engine = mEnginesHelper.getDefaultEngine();
    
    // 2. 构建 Service Intent
    Intent intent = new Intent(TextToSpeech.Engine.INTENT_ACTION_TTS_SERVICE);
    intent.setPackage(engine);
    
    // 3. 跨进程绑定——这才是耗时的根源
    boolean bound = ctx.bindService(intent, mServiceConnection, 
        Context.BIND_AUTO_CREATE);
    
    // 4. 等待 onServiceConnected 回调后设置语言
    // setLanguage() 会在连接成功后异步调用
    return bound ? SUCCESS : ERROR;
}

两个需要注意的点:setLanguage 只在 onInit 回调后才真正生效,构造完立刻 speak 可能因语言未加载而失败;每次 new TextToSpeech 都是一次 bindService,低端设备上跨进程通信耗时 80-120ms。

引擎插件化:靠一个 intent-filter 撑起生态

第三方引擎如何被框架发现?靠 AndroidManifest 中的 Service 声明。系统预置的 Pico TTS 和第三方引擎(Google TTS、讯飞等)用的是同一套注册方式:

<!-- 第三方 TTS 引擎的 Service 声明 -->
<service
    android:name=".MyTtsService"
    android:exported="true"
    android:label="@string/engine_name">
    <intent-filter>
        <action android:name="android.intent.action.TTS_SERVICE" />
    </intent-filter>
    <meta-data
        android:name="android.speech.tts"
        android:resource="@xml/tts_engine_info" />
</service>

TtsEngines.getEngines()PackageManager.queryIntentServices 扫描所有声明了该 action 的 Service,然后解析 tts_engine_info.xml 获取引擎支持的语言列表、采样率、性别偏好等元数据。引擎优先级不由用户评分决定,而是系统设置中的「首选引擎」配置项,存于 Settings.Secure.TTS_DEFAULT_SYNTH

<!-- res/xml/tts_engine_info.xml -->
<tts-engine xmlns:android="http://schemas.android.com/apk/res/android"
    android:locale="zh-CN"
    android:gender="female"
    android:networkRequired="false"
    android:sampleRateInHz="24000" />

这种设计的核心价值在于:框架完全不关心里面是规则合成还是神经网络推理,实现了 onSynthesizeText 回调、写入标准 PCM 音频,就能接入。

合成回调链:字符串到 PCM 的异步路径

speak(text, queueMode, params, utteranceId) 的调用链路比看上去复杂。框架不会直接调到引擎的 onSynthesizeText,中间隔了一层 Action 队列:

speak() → mProcessingQueue → runAction() 
       → mServiceConnection.getService().speak()
       → 引擎进程: onSynthesizeText(text, params, utteranceId)
       → 引擎内部: 文本处理 → 音频生成 → writeAudio()
       → 框架进程: AudioTrack 播放

mProcessingQueue 是单线程顺序执行队列,QUEUE_FLUSHQUEUE_ADD 的差异只在是否清空队列,不改变后续 Action 的执行方式。

我实际测下来,引擎 onSynthesizeText 从调用到 writeAudio 首次回调的间隔,占首字延迟的大头。Google TTS 引擎在高端设备上这个时间约 50-80ms,加上队列等待和跨进程开销,TTFA 轻松超过 200ms。

端侧神经网络声码器:合成质量逼近真人

传统参数合成(formant synthesis)和拼接合成(concatenative)的声音机械感明显。端侧神经网络 TTS 通常分两段:

  • 声学模型(Acoustic Model):文本 → Mel 频谱。常用 Tacotron2 或 FastSpeech 架构,推理出 80 维 Mel 频谱帧序列
  • 声码器(Vocoder):Mel 频谱 → 音频波形。WaveRNN、HiFi-GAN 等 GAN 方案在质量和推理速度上平衡得不错

端侧落地的核心矛盾是模型体积和推理延迟的平衡。我用 TensorFlow Lite 部署过一个 HiFi-GAN 声码器,原始模型 14MB,INT8 量化后压到 3.5MB,单帧推理时间从 12ms 降到 4ms:

// TFLite 声码器推理(简化)
class HifiGanVocoder(modelPath: String) {
    private val interpreter = Interpreter(File(modelPath))
    
    fun melToAudio(mel: FloatArray): ShortArray {
        val output = Array(1) { 
            ShortArray(mel.size * HOP_LENGTH) // hop_length=256
        }
        interpreter.run(mel, output)
        return output[0] // 16-bit PCM
    }
}

量化代价是音质轻微下降,MOS 分从 4.2 降到 4.0。换回的是骁龙 865 上稳定 4ms/帧的推理速度,配合 256 的 hop length,实时因子(RTF)约 0.06——远低于 1.0 的实时线。

实时合成流水线:分块推理 + 环形缓冲

长文本合成是端侧模型的另一个坎。等全文处理完再生成音频,卡顿感会很明显。做法是分句合成 + 流式输送音频

输入文本 → 分句器 → [句子1, 句子2, ..., 句子N]

            声学模型推理 → Mel 帧序列

            声码器推理 → PCM 片段

            AudioTrack.write() → 即时播放

实现上用两个线程和环形缓冲区衔接各阶段。前台线程负责模型推理和音频生成,后台线程(AudioTrack 的 WRITE_NON_BLOCKING 模式)消费缓冲区:

// 环形缓冲流式播放
class StreamingTtsPlayer(bufferSizeMs: Int = 200) {
    private val ringBuffer = ShortArray(SAMPLE_RATE * bufferSizeMs / 1000)
    
    fun onAudioReady(pcm: ShortArray) {
        // 写入环形缓冲,会自动等待 AudioTrack 消费
        ringBuffer.write(pcm)
    }
    
    // AudioTrack 回调线程自动读取并播放
    private val track = AudioTrack.Builder()
        .setBufferSizeInBytes(ringBuffer.size * 2)
        .setTransferMode(AudioTrack.MODE_STREAM)
        .build()
}

这里踩过一个坑:AudioTrack 的 getMinBufferSize 在 Android 10 以下返回偏大(约 8KB),导致播放启动延迟多出 50-80ms。最后直接硬编码 buffer size 为采样率的 1/10(100ms 缓冲),高通和 MTK 平台均稳定跑了。

几点实践取舍

引擎初始化复用:不要在每个 Activity 里 new TextToSpeech。用 Application 级单例持有实例,首帧延迟能省 80ms+。

静默帧预填充:收到第一段有效音频前,先往 AudioTrack 写 20-30ms 静默帧。这不是为了音频填充,而是给 AudioTrack 底层 mixer 线程一个启动预热时间,实测减少 15-20ms 播放抖动。

模型热加载:App 启动后可能立刻要用 TTS 的话,把模型加载放到 Application.onCreate 里。TFLite 的 Interpreter 首次构造时需要分配 Native 内存和编译算子图,耗时 200-500ms,等用户触发时再加载来不及。

引擎选择:我的选择是优先用系统默认引擎(通常是 Google TTS),自研端侧模型作为降级。Google TTS 的语音自然度经过大规模数据训练,自研模型在短文本场景下很难拉开差距,但维护成本高出几个数量级。