资讯动态

Flutter离线语音合成:端侧TTS与声音克隆的完整实践

发布时间:2026/9/8 7:12:47 来源:尧图企业网站定制
我做离线阅读器想做“自定义音色”这个功能时第一个念头还是接云端语音合成 API。但真正动手之后才发现一个以隐私为卖点的本地阅读 App如果每次朗读都要把用户文字内容送到云端再把用户的真人声音采样传到别人服务器上做训练那这个产品逻辑本身就站不住。于是我开始认真研究端侧 TTS 方案最终落在sherpa-onnx 作为推理运行时 ZipVoice 做零样本声音克隆这个组合上并且整套东西内嵌在 Flutter App 里跑通了。这篇文章不是泛泛讲概念是我从选型、导出模型、接 FFI、调延迟、踩爆音坑到最终稳定运行的完整记录希望能给正在做同类功能的开发者省几天时间。1. 为什么我放弃了云端 API把声音克隆放到了本地先交代一下背景。我要做的功能是用户录一段自己的声音10 秒左右然后 App 里所有文字都能用这个声音朗读出来。这类功能在云端实现很成熟但我在实际产品设计阶段确认了三条硬性要求每一条都把方案往端侧逼。1.1 需求的根源离线阅读 App 的“自定义音色”功能阅读类 App 的典型用机场景是地铁、飞机、熄灯后的卧室这些场景要么没网要么网不好要么用户根本不想在这个时候亮屏操作。如果 TTS 依赖在线 API等于把“朗读”这个核心体验建立在不可控的网络上。同时用户录制的声音属于生物特征。我们从产品设计层面就不希望把原始录音上传到服务器也不希望用户的声音特征向量留在云端。这就导致我们必须在用户设备上完成“参考音频 - 声音特征 - 语音合成”的完整链路。加上成本考量——文本转语音的云端调用在大规模用户量下是一笔持续烧钱的支出而且 TTS 时长越长费用越高。把推理放到端侧之后这笔成本直接归零只需要一次性承担模型开发和集成的工时。1.2 云端方案测完后的三个痛点费用、延迟、隐私我在选型阶段把国内外的几家云 TTS 都接了一遍功能上确实都能做到零样本声音克隆但问题也很统一维度云端 API端侧方案首包延迟网络 RTT 排队 合成通常在 1~3 秒纯本地推理首包可压到 300ms 左右隐私音频上传到服务端不出设备长期成本按字符/按时长计费越高频越贵一次性集成无持续费用离线可用不支持完全支持音色相似度通常较高取决于模型和参考音频能达到可用级别维护复杂度低需要自己处理模型升级、端侧兼容这里我要多说一句云服务商在隐私协议里都会声明不做商用训练但“不做训练”不等于“不经过云端”。如果你做的是儿童类、医疗类或高度私密的阅读场景用户对语音上传的敏感度会非常高我实测在测试群里放了个需要上传录音的测试包马上有用户问“我的声音会存在哪儿”——这个问题回答不好产品口碑就受影响。所以最终结论很明确必须端侧。2. sherpa-onnx 和 ZipVoice 在链路里各扮演什么角色很多刚开始接触端侧 TTS 的人会把“sherpa-onnx”理解成一个 TTS 引擎觉得装上它就能直接克隆声音。实际上不是这样需要先把这两个开源项目的边界弄清楚后面集成才不会混淆。2.1 sherpa-onnx负责把 ONNX 模型跑起来的一张通行证sherpa-onnx 是一个跨平台的推理框架它本身不包含声音克隆模型它的作用是让你用同一个 API 去加载和运行各种 ONNX 格式的语音模型。它支持语音识别、语音合成、说话人识别、语音活动检测等多种能力而我这里用到的是它的 TTS 模块。它解决的核心问题是ONNX Runtime 在 Android、iOS、Windows、Linux 上都有但每个平台怎么初始化、怎么喂数据、怎么拿推理结果如果自己写 FFI 绑定工作量不小。sherpa-onnx 把这些封装好了你只需要把模型转成 ONNX然后调它的 C API 或者 Flutter/Dart API 就行。在 Flutter 里官方提供了sherpa_onnx这个 pub 包底层通过 FFI 调用 C 库。这个包已经预编译了各平台的 native 库省去了自己编译的麻烦。2.2 ZipVoice从一段参考音频里抽说话人特征的模型ZipVoice 是这次方案里真正承担“声音克隆”任务的部分。按我的理解它是一种基于神经编解码器的零样本 TTS 模型优势在于模型规模适中适合跑在端侧设备上。它的工作方式不是传统 TTS 那种“训练一个特定音色模型”而是训练一个大模型推理时把参考音频的说话人特征作为条件输入从而生成该声音的语音。这种方案叫“零样本语音克隆”好处是无需针对每个用户微调模型换一个参考音频就能换一种声音。2.3 文本怎么变成声音的完整数据流我在集成时把整条链路拆成了五个阶段方便定位问题文本前端处理中文先做分词和注音转成音素序列。这里要注意不同 TTS 模型对文本前端的要求不同ZipVoice 一般需要带音调的中文音素不能直接喂汉字。说话人编码参考音频经过编码器提取出说话人向量这个向量会作为后续合成模型的 Condition。声学模型推理文本音素序列 说话人向量输入到声学模型生成声学特征常见的是 Mel 谱。声码器合成声学特征通过声码器转换成 PCM 波形。这一阶段直接决定音质上限用的声码器质量差前面做得再好也白搭。后处理与播放PCM 数据封装成 WAV 输出或者直接送播放器。用户在界面上的感知就是录一段声音然后输入文字、点击播放声音出来。但背后其实是两条模型跑了一个完整的“理解文字 - 模仿音色 - 生成波形”的过程。2.4 从 PyTorch 到 ONNX让模型能在 Flutter 里跑的必经步骤ZipVoice 这类模型原始权重通常是 PyTorch 格式不能直接给 sherpa-onnx 用。我做的导出流程是这样的先把模型导入到导出脚本固定好推理时需要的输入输出。用 PyTorch 的torch.onnx.export导出注意opset_version要匹配 ONNX Runtime 支持的版本我用的 11 或更高版本。导出时要把动态轴设置好因为文本长度和音频长度在推理时都是可变的。导出后用onnxruntime在本地跑一遍 Python 推理确认导出后的模型输出和 PyTorch 原模型一致。有一个细节有些模型导出时需要提供示例输入这个示例输入的序列长度会影响模型的 Rosetta 输入形状。即使设置了动态轴如果某个算子本身只支持固定形状导出后推断某个 batch size 就会报错。我碰到过文本过长导致 shape mismatch 的问题后来是把文本按长度切段解决的后面会细说。3. Flutter 接入实操跑通最小 Demo 的关键一步这个部分我尽量直接给可复用的步骤。环境我以 Android 为例iOS 思路一样只是在处理模型文件路径时略有差异。3.1 依赖准备和原生配置在pubspec.yaml里加入dependencies: sherpa_onnx: ^1.10.20 audioplayers: ^6.0.0 path_provider: ^2.1.2sherpa_onnx负责 TTS 推理audioplayers负责播放合成出来的音频path_provider用来获取应用私有目录模型文件不能直接放在 asset 里让 C 库读取所以要先把 asset 复制到私有目录。Android 侧需要在AndroidManifest.xml里加上写权限吗如果模型是打包在 asset 里的不需要。但如果你的 App 允许用户从文件管理器选择参考音频需要加READ_EXTERNAL_STORAGEAndroid 13 以上还需要处理运行时权限请求。3.2 模型文件在 Flutter 里的放置策略模型文件放 asset 里是最直接的方式但有两个问题一是下载安装包体积直接膨胀二是 C 库需要读取磁盘路径而不是 Flutter 的 asset 路径。我的做法是把模型从rootBundle.load()读成ByteData然后写入getApplicationSupportDirectory()后续从这个临时目录加载。这样模型即不占 APK 体积又能在运行时按需更新。核心代码如下FutureString prepareModelFile(String assetPath) async { final dir await getApplicationSupportDirectory(); final fileName assetPath.split(/).last; final file File(${dir.path}/$fileName); if (await file.exists()) { return file.path; } final data await rootBundle.load(assetPath); await file.writeAsBytes(data.buffer.asUint8List()); return file.path; }这里要注意如果模型文件是几百 MB 级rootBundle.load会把整个文件读进内存加上 ONNX Runtime 自己还会开辟内存低端机上很容易 OOM。所以大模型不建议一次性读入我会在后面的优化部分讲怎么解决。3.3 初始化、注册音色、合成、播放四步走初始化 TTS 的配置大致如下import package:sherpa_onnx/sherpa_onnx.dart; final ttsConfig OfflineTtsConfig( model: OfflineTtsModelConfig( // 声学模型和声码器的路径 vits: OfflineTtsVitsModelConfig( acousticModel: acousticModelPath, dataDir: dataDirPath, vocoder: vocoderPath, ), numThreads: 2, debug: false, ), ruleFsts: ruleFstsPath, ruleFars: ruleFarsPath, maxNumSentences: 2, provider: cpu, ); final tts OfflineTts(ttsConfig);然后是注册参考音频音色。根据我用的 ZipVoice 加载逻辑需要先把参考音频转成指定采样率的单声道 WAV再调用addSpeaker接口final speakerId await tts.addSpeaker( referenceWave: referenceWavePath, text: 参考音频对应的文本, );这里有个容易忽略的地方参考音频最好提供它的真实文本。如果模型是基于文本和参考音频联合训练的参考文本越准提取出来的说话人特征就越干净。我实测过用错误文本作为参考时合成出来的声音明显发闷。接下来合成final samples tts.generate( text: 你好这是一段端侧声音克隆测试。, sid: speakerId, speed: 1.0, );samples是一个Float32List如果要播放先转成 WAV 格式字节再交给audioplayersfinal wavBytes bytesToWav(samples, sampleRate); final tempDir await getTemporaryDirectory(); final wavFile File(${tempDir.path}/output_${DateTime.now().millisecondsSinceEpoch}.wav); await wavFile.writeAsBytes(wavBytes); final player AudioPlayer(); await player.play(DeviceFileSource(wavFile.path));到这里一个最小 Demo 就能跑起来了。4. 从能出声到好用流式合成、线程隔离与延迟优化能出声之后下一步是让它“好用”。这一部分我踩了不少坑尤其是首包延迟和 UI 卡顿这两个问题在 Flutter 里尤其明显。4.1 流式还是非流式看使用场景sherpa_onnx的 TTS 支持两种方式一次性生成整段 WAV 和流式回调。前者简单但长文本会有两个问题内存占用高。用户等待时间长直到整段合成完才能播放。我一开始贪图简单用非流式合成朗读一整章小说结果在低端 Android 手机上内存直接飙到 400MB 以上而且合成 10 秒音频的过程中播放器是空的用户会以为卡死了。后来改成流式final samples tts.generateWithCallback( text: longText, sid: speakerId, speed: 1.0, callback: (audio, start, stop) { // 把实时生成的音频块追加到播放缓冲区 }, );实现流畅播放时我建议用双缓冲一个缓冲区在后台累积音频一个缓冲区在播放器线程消费。音频块到达后先灌进队列等积累到 200~300ms 的音频量再启动播放避免断断续续。这个逻辑我在后面踩坑部分会展开。4.2 用 compute 隔离省心还是手动开 Isolate 更稳Flutter 的单线程模型决定了所有 Dart 代码默认跑在 UI Isolate 上。sherpa-onnx 的调用底层虽然是 C但 Dart 的 FFI 调用如果直接在 UI Isolate 执行推理解析过程会把 UI 卡住。我最初没有意识到这个问题合成 5 秒音频时界面直接掉到个位数帧率。解决方案有两个用compute函数把 TTS 调用放到后台 Isolate。自己开一个专用 Isolate通过SendPort/ReceivePort通信。我测下来对一次性合成compute最简单有效。但对流式合成用compute就不方便了因为回调函数需要持续往 UI Isolate 传数据。这种情况下我建议手动开 Isolatefinal isolate await Isolate.spawn(ttsWorker, receivePort.sendPort);在ttsWorker里创建OfflineTts实例然后循环接收主 Isolate 发来的任务把合成结果用SendPort发回去。有一个坑必须提醒ONNX Runtime 初始化时创建的线程池绑定在 Isolate 上如果你每次合成都在compute里重新创建 TTS 实例模型加载和资源释放的开销会很大。更好的做法是让 Isolate 常驻TTS 实例只初始化一次后面反复复用。4.3 长文本怎么切分缓存怎么做我在项目里把长文本按标点切分成短句每句独立合成。这样做的好处是显而易见的避免模型在超长文本上推理时 shape mismatch。用户能更快听到开头。可以做增量播放文本边读边合成边播。但副作用是破坏了句间停顿和连贯性。我的解决方法是切分时保留标点符号本身作为一个独立的单位合成后在一个句子结束后强制插入一个固定长度的静音段用这个静音来模拟句读停顿。至于段落之间再多插一段 500ms 的停顿听起来就比较自然。缓存策略我用了最朴素但很有效的一招把“文本 说话人 ID 语速”三者拼成 key合成结果存到本地目录。同一段文字再次朗读时直接读缓存不再触发推理。实测阅读类 App 中用户反复听同一段书的场景还挺多的缓存命中后体验提升非常明显。5. 踩坑实录声音不像、爆音、Release 包闪退这一部分都是真金白银换来的教训。如果你的端侧声音克隆项目也出现了类似问题下面这些排查路径大概率能帮你定位。5.1 声音不像先检查参考音频而不是模型声音克隆效果不佳时大家第一反应是“模型不够好”但我调试下来80% 的相似度问题出在参考音频的预处理上静音太多开头和结尾的静音会让模型提取到噪声特征合成出来的声音有“嘶嘶”的底噪。时长不合适参考音频太短比如 1 秒说话人特征不完整太长超过 30 秒模型会混入多种语气和情绪反而模糊了音色一致性。采样率不对模型要求 16kHz 或 24kHz 的输入直接用 44.1kHz 的录音文件会导入失败或产生高频失真。我的标准处理流程是读取用户录音 - 用 FFmpeg 重采样到模型要求的采样率 - 转成单声道 - 用 VAD 切掉静音 - 截取前 5~10 秒作为参考。FFmpeg 命令行以 16kHz 单声道输出为例ffmpeg -i input.m4a -ac 1 -ar 16000 -af silenceremovestart_periods1:start_threshold-50dB:start_silence0.5,areverse,silenceremovestart_periods1:start_threshold-50dB:start_silence0.5,areverse output.wav这条命令会同时切除首尾静音。在 Flutter 端我用了ffmpeg_kit_flutter来做离线转码。5.2 播放爆音的排查链路合成出来的音频直接用audioplayers播放发现一个很奇怪的现象音频前半段正常后半段开始有“滋滋”的爆音。排查过程大概是这样的第一步排除模型问题。我用同样的输入文本在 Python 端推理一次保存成 WAV 用电脑播放完全正常。这个实验说明模型本身没有产生爆音。第二步排除数据截断。把 Dart 生成的 WAV 文件拉到电脑上播放发现还是有爆音说明问题出在 Dart 端合成或写文件环节。第三步检查 Wave 头部信息。最后发现是我自己写的bytesToWav函数里字节序写错了。PCM 数据在小端机器上没有问题但我在写dataSize和sampleRate时用了大端序导致播放器读取的时候信息错位。修改后问题消失。这类问题最有效的排查技巧是把文件头信息打出来跟标准 WAV 格式对比。网上有很多在线 WAV 头解析工具贴进去一比对就能看出来哪个字段不对。5.3 Release 闪退和 asset 路径的坑Debug 模式下一切正常打包成 Release 之后一进 TTS 页面就闪退。遇到这类问题第一反应不要是去看业务代码先看崩溃日志里的 native 层堆栈。我的崩溃原因是模型文件路径传错了。Debug 模式下我直接通过rootBundle.load把模型数据写入了临时目录Release 构建后 Flutter 的 asset 文件名可能发生变化具体表现为前面带了类似flutter_assets/的前缀直接用原始 asset 路径去拼接读出来就是空文件。ONNX Runtime 加载空文件时直接崩。解决方式是在打包前用AssetManifest.json查一下实际的 asset key或者干脆在读取时做一层兜底String assetPath assets/models/xxx.onnx; if (!await rootBundle.loadString(assetPath).then((_) true).catchError((_) false)) { assetPath flutter_assets/assets/models/xxx.onnx; }但兜底逻辑只适合快速定位问题真正可靠的姿势是统一用AssetManifest.json动态获取。另外还有一个 Release 特有的问题R8 混淆规则没有把 FFI 用到的 JNI 方法 keep 住导致 Release 包在 native 初始化时直接 UnsatisfiedLinkError。需要在proguard-rules.pro里加上-keep class com.k2fsa.sherpa.onnx.** { *; }5.4 关于 One-shot 声音克隆的边界与使用提醒这个是合规层面的提醒也是我调研之后在项目里强制加的约束。声音克隆技术现在很敏感无论是产品形式还是工程实现都必须确保用户录制参考音频时明确了用途最好在设置音色的界面放一段授权声明并且允许用户随时删除自己的声音特征数据。技术上我只在本地保存说话人特征向量不保存原始录音。特征向量只用一次参考音频合成完特征之后就主动删除。这样即便用户卸载 App手机上也不会残留同样的声音原始素材。我个人对这项技术的态度是用在家人音色朗读、有声书个性配音这些场景没问题但一定不能把它包装成“替别人说话”的工具。产品边界清楚技术才能长期活下去。6. 这套方案的实际体验和还能扩展的方向最后聊聊这套方案现在跑下来的实际体验以及我觉得后续值得尝试的扩展。6.1 现在的效果定级能用、可用、好用之间是哪一档直接说结论经过参考音频预处理和缓存优化之后这套链路在中端 Android 机型上10 秒音频的合成耗时约 2~3 秒首包 300ms 左右内存稳定在 200MB 以内。音色相似度主观评价大概在“能明显听出接近原声”这个级别跟顶级云端 API 的“以假乱真”还有差距但结合离线、隐私、零成本这三个优势胜在实用。6.2 更多扩展本地多音色库、按场景后台生成当前实现是单音色注册每次切换参考音频都要重新提取特征。后续我想做的是允许用户创建多个音色并存每个音色对应一个说话人 ID。界面里做成一个“音色库”切换时不用重新加载模型只是换一个 Speaker Embedding 而已。另一个思路是预生成的“离线合成任务队列”。用户可以在有 WiFi 时提前输入文字App 后台批量合成音频并缓存这样用户真正阅读时就是纯播放连合成延迟都省了。这个场景特别适合通勤族提前缓存当晚的阅读内容。除此之外我还在尝试把合成流程和阅读进度绑定当用户翻到某一页时提前合成下一页的音频。这样用户基本感知不到等待过程体验接近实时。这套方案在平板上的多任务场景也很实用——一边播放、一边阅读、一边做笔记。6.3 我的几条经验第一个经验是端侧 TTS 的性能瓶颈往往不在模型推理而在数据搬运和对象生命周期管理。大量时间会耗在 WAV 头封装、文件 IO、Isolate 通信上。设计时就要把这些称为一等公民而不是事后补救。第二个经验是模型文件加载方式决定了 App 的启动速度和运行稳定性。我最开始用一次性读入闪退率明显偏高后来改成按需加载和缓存复用情况好很多。模型体积和推理速度是矛盾但“按需加载 后台预加载”能同时兼顾。第三个经验是验证每一步要独立。不管是用 Python 验证模型输出、用电脑播放器验证 WAV 文件还是用系统播放器验证流式播放每一步都单独验证过再集成到 Flutter 里排错效率会高非常多。最忌讳的就是问题一出现就怀疑模型本身然后盲调参数。这套方案目前已经稳定跑过三个 Android 机型的回归测试后续我还在做 iOS 的适配。如果你也在做 Flutter 里的端侧 TTS 或者声音克隆建议先按文章里的步骤把最小 Demo 跑通再根据真实使用场景去优化流式、缓存和线程隔离。只要把参考音频处理和模型文件加载这两个最容易踩坑的环节控制好整条链路比想象中稳定得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价