拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Flutter端侧声音克隆TTS实战:sherpa-onnx + ZipVoice离线方案

如果你正准备在 Flutter 里做一款带“声音克隆”能力的离线文字转语音应用也就是用户录几秒钟自己的声音然后 App 就能用这个音色把文字读出来那这套组合应该是当前社区里少见的、能完整跑通的端侧方案sherpa-onnx 负责离线 TTS 推理ZipVoice 负责端侧声音克隆最终在 Flutter 层把它们拼成一条完整的语音合成链路。它不需要服务器不产生 API 费用数据也不会离开设备适合阅读类 App、有声陪伴、无障碍播报这类对隐私和延迟敏感的场景。这篇文章我会从方案选型开始讲逐步拆解 Flutter 工程里怎么接 sherpa-onnx、怎么把 ZipVoice 的声音克隆输出喂给 TTS 引擎、怎么调听感、又在哪些环节容易翻车。全程以我们实际跑过的链路为主线给的是可以直接对照落地的步骤而不是原理堆砌。1. 为什么是 sherpa-onnx ZipVoice 的组合1.1 端侧 TTS 和云 TTS 的本质差异先明确一个核心问题TTS 放端上做到底图什么。最直接的是三点一是离线可用地铁里、飞机上、弱网环境都不影响二是零成本云 TTS 按字符收费长时间朗读场景成本很可观三是隐私可控尤其涉及个人语音数据时把参考音频传到云端本身就是一种风险。但端侧 TTS 有一个老问题模型够不够轻、推理够不够快。在手机上跑一个几百 MB 的神经网络模型不能只看能不能加载还得看首包延迟、CPU 占用、内存峰值和发热。sherpa-onnx 这个项目我用下来的感受是它把基于 ONNX Runtime 的离线语音推理做得很完整支持语音识别ASR、语音合成TTS、说话人识别等任务并且针对手机端做过不少裁剪和优化。Flutter 侧也有对应的 Dart 绑定不用自己从头写一堆 FFI 胶水代码。传统 TTS 链路通常是“文本前端 - 音素规整 - 声学模型 - 声码器”不同模块可能由不同框架负责串联起来很麻烦。sherpa-onnx 走的是端到端路线模型文件给到它文本进去PCM 音频数据出来中间环节被压缩到极致。这也是它能在 Flutter 里做成完整方案的原因。1.2 ZipVoice 到底补上了哪一块光有 TTS 还不够因为普通 TTS 默认只有固定几个音色更谈不上“用某个人的声音朗读”。ZipVoice 这类端侧声音克隆工具的定位就是干这件事输入一段几秒钟的参考语音提取出说话人的音色特征通常是一个高维向量也叫说话人 embedding然后把这个特征作为条件交给语音合成模型让合成结果带上这个人的声音特点。这里要强调一个容易误解的点声音克隆并不是把参考音频剪碎再拼接而是学习“这个人怎么说话”的声学特征再用这些特征去驱动文本生成语音。ZipVoice 和 sherpa-onnx 的组合逻辑是ZipVoice 负责端侧提取说话人特征sherpa-onnx 负责用这个特征完成语音合成。听起来像是“两个模型串起来”但真正落地时关键在特征对接这个我在第 3 章会展开讲。1.3 主流方案对比选这套的理由在 Flutter 里做 TTS见过不少人一开始会走下面几条路方案延迟是否离线音色可控是否支持克隆Flutter 集成成本系统自带 TTS低是差不支持低云 TTS API中/高否中部分支持低本地 VITS 自建推理中是中需要额外开发高sherpa-onnx ZipVoice低是高支持可控系统自带 TTS 最大的问题是音色和语速在不同品牌手机上差异巨大开发者没法保证所有用户听到的是一致的。云 TTS 虽然效果好但每次合成都要网络请求长文本场景延迟和费用都上去了。自己用 Python 把 VITS 模型包装成服务再让 Flutter 调又退回了“伪端侧”的老路而且移动端模型转换和推理优化是个大坑。所以 shoot 下来sherpa-onnx 做推理底座ZipVoice 做音色提取两者都在本地完成是当前 Flutter 技术栈里比较务实的端侧 TTS 克隆组合。它的核心价值是你不需要自己搭一个推理服务也不需要把用户语音样本上传到任何地方。1.4 整体链路长什么样用文字描述完整链路大概是这样的参考音频录制3 到 10 秒 - ZipVoice 提取说话人 embedding - 用户输入任意文本 - sherpa-onnx 加载 VITS 类声学模型 - 将 embedding 注入模型 - 推理输出 PCM 音频数据 - Flutter 播放器播放。这里面的关键节点有两个第一ZipVoice 输出的 embedding 必须和 sherpa-onnx 里那个模型支持的输入格式对齐第二最终播放时 PCM 采样率要和播放器配置一致否则声音会变调。后面章节的实操都是围绕这两个关键节点展开的。2. Flutter 工程侧的整体接入思路2.1 依赖选型和工程准备Flutter 侧需要关心的依赖主要有三类推理绑定、音频播放、文件路径处理。推理绑定可以用 sherpa-onnx 官方维护的 Dart 包它底层通过 FFI 调用 C 库封装了文本转语音的接口。音频播放我推荐 just_audio 或者 audioplayers因为合成结果是一段 PCM 数据播放器需要支持从内存数据流播放。文件路径处理用 path_provider主要解决模型文件存放问题。flutter pub add sherpa_onnx just_audio path_provider工程准备上Android 端注意一下 minSdk 版本建议 23 以上NDK 和 CMake 也要装好因为 sherpa-onnx 的 Android 构建依赖这些工具。iOS 端则要求最低版本 13 以上同时注意 App Store 对本地推理应用没有特殊限制按普通 App 提审即可。2.2 模型资源怎么放离线 TTS 模型包通常包含一个 .onnx 主模型文件配套的 tokens.txt字符到 token 的映射表、lexicon.txt词典、dict 目录中文分词相关资源、espeak-ng-data多语言音素规则数据。这些文件加起来大小从几十 MB 到几百 MB 都有不能直接当普通 Flutter asset 反复读取否则每次启动都要触发系统资源复制启动时间会非常难看。建议的做法是把模型文件声明在 pubspec.yaml 的 assets 里首次启动时用 rootBundle 读取出来写到应用私有目录比如 getApplicationDocumentsDirectory后续直接从磁盘路径加载。这样既保证安装包自带模型又避免每次合成时都做一次资源 IO。2.3 初始化 TTS 引擎sherpa-onnx 的初始化逻辑非常直白核心就是构建一个离线 TTS 配置对象指定模型路径和配套资源路径。下面是一段可以对照运行的 Dart 代码import package:sherpa_onnx/sherpa_onnx.dart; final ttsConfig SherpaOnnxOfflineTtsConfig( model: SherpaOnnxOfflineTtsModelConfig( vits: SherpaOnnxOfflineTtsVitsModelConfig( model: $modelDir/model.onnx, tokens: $modelDir/tokens.txt, lexicon: $modelDir/lexicon.txt, dictDir: $modelDir/dict, dataDir: $modelDir/espeak-ng-data, ), numThreads: 2, debug: false, ), ruleFsts: $modelDir/phone.fst, ); final tts SherpaOnnxOfflineTts(config: ttsConfig);注意几个容易踩的点tokens.txt 必须和模型配套换模型一定要换 tokens否则中文会变成乱码或直接不出声。如果是纯中文 VITS 模型很多开源模型并不强制需要 espeak-ng-data但为了英文混读的效果建议还是把 dataDir 配上。numThreads 在手机端不要开太大2 到 4 线程就够开多了反而增加调度开销。2.4 为什么底层必须走 FFI而不是纯 Dart有朋友问过能不能用 Dart 直接做神经网络推理。答案很现实不能至少现在不能。神经网络的算子库、内存管理、并行调度都是 C/C 层的强项纯 Dart 做这类计算既慢又容易卡 UI。sherpa-onnx 的 Flutter 包本质上是把 C 推理引擎编译成 Android 的 .so 库和 iOS 的 .a 库再通过 dart:ffi 暴露给 Dart 调用。这就是“Flutter 负责界面和业务C 负责跑模型”的经典分工。实际开发时千万别在 UI isolate 里直接调用 TTS 合成接口。合成一长段文字可能要几百毫秒到几秒如果不放到后台 isolate 或者至少用 compute 包裹用户滚动页面时就能明显感觉到掉帧和卡顿。3. 把 ZipVoice 的声音克隆串进 TTS 链路3.1 参考音频的准备和处理声音克隆的第一步是准备参考音频。ZipVoice 对输入音频是有基本要求的格式最好是 16kHz 采样率、16bit、单声道的 WAV时长控制在 3 到 10 秒之间。录音时注意无背景噪声人声清晰不要有音乐或混响。为什么一定要 16kHz因为大部分语音模型训练时用的就是 16kHz 的采样率过高采样率反而会导致频率特征分布和训练数据不一致。时长也需要控制。太短的音频比如 1 秒提取不出稳定的说话人特征太长的音频比如 30 秒又容易混入情绪波动和语气差异导致提取出的 embedding 不够“纯粹”。我个人的经验是4 到 5 秒的普通话陈述句效果最好音素覆盖广比如“今天天气不错我们一起去公园散步吧”这种句子包含的声母韵母足够丰富能提取出稳定的音色特征。如果录音设备出来的音频是 48kHz 或者 44.1kHz需要在端侧做一次重采样。这一步可以用简单工具或音频库完成目标就是把它变成 16kHz 单声道 WAV。3.2 说话人特征注入的两种核心方式这里讲整个方案里最关键的细节ZipVoice 提取到的 embedding 怎么交给 sherpa-onnx。从我调研和实际接触的情况看当前主流开源 VITS 类模型有两种说话人控制方式。第一种是离散说话人 ID 加预训练 embedding 表模型在训练时见过固定数量的说话人每个 ID 对应一个高维向量。这种方式在克隆场景里需要做“映射”把 ZipVoice 提取的向量去匹配最接近的说话人 ID或者直接替换 embedding 表里对应位置的向量。第二种是连续说话人 embedding 作为条件输入模型本身支持 zero-shot 克隆训练时未见过的新声音可以直接通过输入 embedding 参与合成不需要重新训练。如果你的 sherpa-onnx 模型是第一种打通 ZipVoice 的常规思路是先制作一个“说话人映射文件”把 ZipVoice 输出的 256 维或 512 维向量写入模型支持的 speaker 配置如果是第二种那链路更直接把 ZipVoice 的输出转成 ONNX 需要的 tensor 格式作为额外输入喂给模型即可。具体用哪种需要先确认手里的模型是 “speaker-id 型 VITS” 还是 “zero-shot 型 VITS”。3.3 在 Flutter 层封装一个克隆合成服务为了不让业务代码被底层细节淹没建议在 Flutter 层封装一个 VoiceCloneController。它对外暴露两个能力一个是“设置当前用户音色”接收一段参考音频返回说话人 embedding另一个是“用当前音色合成文本”接收文字返回 PCM 数据。这样 UI 层不需要关心 ZipVoice 还是 sherpa-onnx。class VoiceCloneController { final SherpaOnnxOfflineTts _tts; final ZipVoiceService _zipVoice; Float32List? _currentEmbedding; VoiceCloneController(this._tts, this._zipVoice); Futurevoid setSpeakerFromWav(Uint8List wavBytes) async { final embedding await _zipVoice.extractEmbedding(wavBytes); _currentEmbedding embedding; } FutureUint8List speak(String text) async { if (_currentEmbedding null) { throw StateError(请先设置参考音频); } // 这里需要将 embedding 转换为模型可接受的输入格式 final pcmBytes await _tts.generateWithSpeakerFeature( text: text, speakerFeature: _currentEmbedding!, ); return pcmBytes; } }这段代码里的 generateWithSpeakerFeature 是一个抽象后的接口真实调用方式取决于你使用的 sherpa-onnx 版本和具体模型。我在项目中是直接 fork 了官方 Flutter package在 FFI 层加了一个“传入 float 数组作为说话人特征”的扩展方法再编译出自己的 .so/.a 文件。过程不算复杂但需要一点点 C 和 NDK 知识这也是整条方案里技术门槛最高的部分。3.4 播放链路和采样率匹配合成出来的是 PCM 数据不是文件不能直接交给系统播放器播放。我使用的链路是拿到 PCM 字节后先封成 WAV 格式或者直接作为原始 PCM 流交给 just_audio 的 StreamAudioSource 播放。封装 WAV 的好处是兼容性最好很多播放器可以直接播放。需要留意的是在写入 WAV 头时采样率必须填 sherpa-onnx 合成时实际输出的采样率常见的是 22050Hz、24000Hz 或 44100Hz。填错采样率声音听起来就会变成“小熊说话”也就是所谓的变速不变调。final pcm await controller.speak(你好欢迎体验端侧声音克隆); final wavBytes _wrapPcmWithWavHeader( pcmData: pcm, sampleRate: tts.sampleRate, numChannels: 1, bitsPerSample: 16, ); await _audioPlayer.setAudioSource( StreamAudioSource.fromBytes(wavBytes), ); await _audioPlayer.play();播放器如果出现卡顿可以检查 isDynamicSampleRate 之类的配置。不同插件对原始 PCM 的采样率处理策略不一样能显式指定采样率就一定显式指定。4. 听感调优与端侧性能打磨4.1 影响克隆音色的核心参数合成阶段有几个参数直接决定听感不能只看模型傻跑。sherpa-onnx 的 VITS 配置里常见的有 noiseScale、noiseScaleW 和 lengthScale。用生活化类比解释noiseScale 控制“情绪波动的剧烈程度”值越大声音越有起伏但过大就会像喝多了说话noiseScaleW 控制“音节内部的随机抖动”影响气息感和自然度lengthScale 控制“语速的拉伸比例”大于 1 是减慢小于 1 是加快。参数作用推荐范围调太大后果noiseScale控制韵律随机性0.6 到 0.8声音发抖、不自然noiseScaleW控制音素级抖动0.7 到 0.9气息过重、咬字不清lengthScale控制整体语速0.8 到 1.2过慢拖沓过快失真不同的模型对同一组参数的反应差异很大我通常先用默认参数合成一句长文本再根据听感微调。注意不要为了追求“更像原声”而无脑调大 noiseScale那会让克隆出来的声音变得“炸”。4.2 首包延迟优化重点是预热和缓存端侧 TTS 的体验瓶颈往往是第一次合成特别慢。原因有两部分一是模型首次加载需要解析结构、分配内存二是操作系统的文件缓存还没生效。预热是解决首包延迟最有效的手段App 启动后立刻在后台 isolate 合成一句非常短的文本比如“准备”让引擎先跑一遍。另一个非常有用的策略是缓存 ZipVoice 的 embedding。同一个用户录制一次参考音频之后embedding 基本是固定的没必要每次合成都重新提取。把 embedding 序列化到本地文件下次启动直接加载能省掉 200 到 500 毫秒的预处理时间。4.3 用量化模型和裁剪降低资源占用对移动端来说模型体积和运行时内存是硬指标。一个完整的 VITS 模型动辄 300 到 600MB在低端 Android 机上很容易触发内存压力。优先选择 int8 量化的 ONNX 模型体积通常能压缩到原来的四分之一到一半。量化带来的代价是音色细节可能略有损失尤其是高频的气音和齿音但多数场景下听感差异可接受。模型裁剪是更激进的手段。如果 App 只需要中文单说话人可以把多说话人模型切成单说话人版本或者直接用单说话人训练好的 VITS 模型做底座再把 ZipVoice 提取到的说话人特征映射进去。这个操作依赖具体模型结构不适合零基础操作但收益显著内存占用能降 30% 以上。4.4 多端部署的几个差异点Flutter 的优势就是跨端但端侧模型部署不能一套代码走天下。Android 端要打包 arm64-v8a 和 armeabi-v7a 两个架构的 .so如果你的目标机是 x86 模拟器还要单独处理 x86_64 的库。iOS 端要注意模拟器架构和真机架构不同使用 pod 或打包静态库时容易踩架构不匹配的坑。Windows 端则要注意路径分隔符模型路径里用反斜杠还是正斜杠在不同环境里可能有坑统一用项目根目录拼路径更稳。Web 端也可以通过 WASM 跑 sherpa-onnx但受限于浏览器内存和单线程目前体验远不如移动端流畅做 Demo 演示可以生产环境建议慎重。5. 常见问题与排查实录5.1 问题速查表现象可能原因解决方案合成中文变成乱码或不出声tokens.txt 和模型不匹配换回配套的 tokens 文件有声音但明显变调播放器采样率和合成采样率不一致用 tts.sampleRate 设置播放器克隆音色不像原声参考音频太长/太短/噪声大换成 4 到 5 秒干净讲话录音首次合成特别慢模型未预热、大文件 IO启动时后台预热合成短句多次合成后内存上涨每次合成新建对象未释放复用 TTS 实例避免重复初始化部分 Android 机型崩溃内存不足或架构未打包全使用 int8 模型检查 .so 架构英文混读效果差缺少 espeak-ng-data配置 dataDir或使用支持中英混读的模型embedding 注入后声音没有变化模型不支持该注入方式确认模型是 zero-shot 还是 speaker-id 型5.2 独家避坑心得先说说参考音频这件事。我遇到过很多次用户录了 15 秒甚至 30 秒的音频结果克隆出来的声音反而“四不像”。原因我说了说话人 embedding 是整段音频的统计特征时长太长会把多种语气和情绪混在一起特征不再纯粹。实测下来5 秒是最稳的区间三句话左右陈述语气背景干净。再有一个就是合成任务别在同一个 isolate 里无限堆积。如果用户快速触发多条朗读建议做一个“当前任务取消 最新任务覆盖”的机制否则音频回调会排队表现为越来越慢最后直接卡死。这里可以用 Dart 的 Future 链配合一个 generation token每次开始新合成前使旧 token 失效回调回来时判断 token 是否已经失效失效就直接丢弃。最后一个心得是关于工程排错的顺序。遇到音频问题先用原始 PCM 数据写一个 WAV 文件分享到电脑上听确认到底是合成侧的问题还是播放侧的问题不要凭感觉改参数。像“声音嘶哑”“声音发闷”这类问题大多数时候不是模型坏了而是采样率、声道数或者播放配置出了偏差把数据落盘检查是最快的定位手段。这套方案后续还有一个很有趣的扩展方向把同一套链路从“文本 - 用户音色语音”升级成“对话 Agent 用户音色语音”也就是让聊天助手用自己的声音把回答内容读出来。技术上只需要在 speak 方法前面接一个大模型流式输出中间加一层文本缓冲区控制切句时机音频体验就能自然很多。只要模型文件和 embedding 对应关系维护好这套底座可以支撑不止一个产品形态。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门