资讯动态

AI对话App语音输出实战:sherpa-onnx TTS服务与流式播放

发布时间:2026/10/10 7:41:59 来源:尧图企业网站定制
既然要做的是AI对话App TTS语音这个方向那这篇文章的重心就很清楚了不是教你调一个云厂商的语音接口而是把对话App里文字生成、语音输出这条完整链路从零到一打通。我按实际开发顺序来写把我踩过的坑、验证过有效的方案、以及性能和体验上必须注意的细节都放进去。1. 这项目的核心问题对话生成文字容易让App开口说话才是难点如果你只是给App加一个朗读回复的按钮那用系统自带的TextToSpeech就够了。但要做成一个真正能开口对话的AI语音助手核心链路其实是这样的用户说话或打字→ 将文本交给大模型大模型返回文字回复流式输出App把完整回复切分成句子每个句子交给TTS引擎合成音频播放音频同时展示文字看起来简单但第4步会卡住很多人尤其是当你的App需要同时兼容iOS、Android、小程序甚至还要考虑离线环境和成本的时候。我从一开始就锁定了几个硬性需求延迟要低用户说完话最好1秒内听到首个语音反馈成本要可控不能按字符数付费否则用户多聊几次就烧钱可离线运行至少核心TTS引擎要能在本地跑音色自然不能是那种机械的机器人声基于这些需求我最终走的是sherpa-onnx 自建TTS微服务 前端流式播放的方案。下面把每一步展开讲包括代码和配置你可以直接照着搭。2. TTS方案选型对比为什么我放弃了云厂商和edge-tts先说结论如果你只是做个Demo云厂商TTS讯飞、阿里云、腾讯云和微软的edge-tts都能用。但如果你要做成一个能长期运营、给真实用户使用的App这两个方案都有绕不开的问题。云厂商TTS的问题按调用次数或字符数计费日活过千之后是一笔不小开销网络抖动时语音响应卡顿用户体感很差语音数据要经过第三方服务器很多场景比如医疗咨询、企业内训对数据隐私有硬性要求这里补充一个大家容易忽略的点云厂商TTS的流式合成接口往往不是真流式的而是要等整段文本全部合成完才开始返回音频数据。你在官方文档上看到的是边合成边返回实际上单次请求的耗时和文本长度强相关文本一长首包延迟就上去了。如果你做的是实时对话用户在等大模型输出的同时还要再等TTS从头合成这个等待时间就叠加了。edge-tts的问题微软edge-tts质量确实不错而且免费但它本质是爬虫式调用有被限流和封禁的风险。用在生产环境不靠谱而且TTS引擎在海外国内直连延迟不稳定。适合自己玩玩不适合上线。这里有个教训我之前有个朋友做朗读类App为了省成本用了edge-tts用户量上来之后IP被封了所有语音一夜之间全挂投诉邮件直接把邮箱塞满了。所以生产环境千万别赌免费接口。sherpa-onnx的优势sherpa-onnx是k2-fsa社区出的一个开源推理引擎它不只是TTS还支持流式和非流式的语音识别ASR、VAD语音活动检测、说话人日志、标点恢复等是完整的一套离线语音工具箱。我只用了TTS部分但它的架构对后面扩展语音交互非常有价值。我用它有几个具体理由纯本地推理装进iOS、Android、桌面端或部署在服务器上都行不依赖任何云端API多语言多音色支持中文、英文、粤语等音色模型很多CPU友好用ONNX Runtime推理普通手机跑起来也没压力模型开源免费社区一直在更新有vits、matcha、kokoro等架构可选自带VAD模型搭配流式合成可以做边说边打断的对话交互这一点在后面做真·语音对话时非常关键因为人说话总有停顿没有VAD就只能靠录音固定时长这种笨办法架构层面我分两层用一个IOs客户端的集成方案直接用它的Swift/SDK加上本地模型适合做完全离线可用的纯端侧版本。我现在做的是在线版本所以在后端单独起了服务前端通过HTTP和WebSocket来取音频流。这样项目分成了两个可独立迭代的部分。下面是Android端早期验证时用Java调简单SentenceBoundaryDetector的示例理解思路即可val config OfflineTtsConfig(model modelConfig) val tts OfflineTts(config) val audio tts.synthesize(你好欢迎使用离线语音合成)3. 实战开发从对话流到语音流的完整实现3.1 前端对话逻辑用流式输出提升体验对话App的前端我用Vue3 Vite搭建核心是useChat这个来自Vercel AI SDK的组合式函数。它负责把用户输入发给后端大模型接口并处理流式返回的文字。它同时提供isLoading、messages等状态省去了自己管理WebSocket或fetch流的琐碎工作。安装npm install ai ai-sdk/vue核心思路是拿到大模型流式返回的完整文本后按标点符号切成句子每切出一个句子立即丢给TTS服务去合成。这样用户看到文字的同时语音也自然跟上不会等大模型全部写完才开始朗读。用户看到屏幕上文字逐渐变多耳边同步听到朗读体验上接近真人对话的速度感。前端关键代码片段import { useChat } from ai/vue const { messages, sendMessage, isLoading } useChat({ api: /api/chat, }) // 拿到最新的完整回复 watch(() messages.value[messages.value.length - 1]?.content, (content) { if (content) { const sentences splitIntoSentences(content) sentences.forEach(sentence enqueueSpeech(sentence)) } })splitIntoSentences按中文的句号、问号、感叹号、分号来切也可以结合正则处理引号内的内容。这里有个细节朗读时遇到引号内容会用不同音色来读这个后面在踩坑部分细说。enqueueSpeech是把句子推入一个队列播放器按顺序播放。为什么用队列而不是直接播放因为TTS合成需要时间如果每个句子都独立播放后一句的合成时间如果比前一句播放长就会出现卡顿。队列预合成的机制能保证语音是连续、不中断的。我在队列里最多预合成3个句子超过就在本地等待防止短时间大模型吐字太多导致内存占用过高。3.2 TTS服务端用FastAPI封装sherpa-onnx服务端我用Python FastAPI在启动时加载一次模型然后提供HTTP接口接收文本、返回音频流这是最稳的组合。模型文件我从GitHub Releases上下载用的vits中文模型vits-cmn-aishell3女声模型vits-cmn-japanese中英日三语混合模型加载代码import sherpa_onnx tts_config sherpa_onnx.OfflineTtsConfig( modelsherpa_onnx.OfflineTtsModelConfig( vitssherpa_onnx.OfflineTtsVitsModelConfig( modelvits-cmn-aishell3.onnx, tokenstokens.txt, lexiconlexicon.txt, dict_dirdict, ), ), rule_fstsphone.fst, ) tts sherpa_onnx.OfflineTts(tts_config) tts.set_speed(1.0)值得注意的是rule_fsts参数如果你不做中文数字、日期、金额的口语化可以不填。但如果用户发送2024年3月5日你希望语音读成二零二四年三月五日而不是二〇二四年三月五日就需要加载这个FST规则文件。这类细节最影响听感。我在实际测试中发现缺了它遇到年份表达、百分比、分数时TTS会按字面读出数字比如3/5会被读成三五而不是五分之三这在对话场景里很尴尬。合成接口from fastapi import FastAPI from fastapi.responses import StreamingResponse import io import numpy as np app FastAPI() app.get(/tts) def synthesize(text: str, speaker_id: int 0): audio tts.synthesize(text, sidspeaker_id) samples np.array(audio.samples, dtypenp.int16) # 如果采样率不是24000重采样到24000 if audio.sample_rate ! 24000: samples resample(samples, audio.sample_rate, 24000) wav_bytes pcm_to_wav(samples, 24000) return StreamingResponse(io.BytesIO(wav_bytes), media_typeaudio/wav)音频生成后前端拿到一个完整的WAV文件并播放。一次请求对应一个句子的音频单句时长一般在3到8秒之间生成时间在几百毫秒级别体感很流畅。注意sid参数同一套模型可以加载多个音色aishell3模型内置了多个说话人ID。你可以给不同的场景分配不同的音色比如默认女声、故事模式下换男声而且不需要额外加载多个模型文件只多传了一个整型参数性价比极高。3.3 播放模块AudioContext解码队列播放前端用AudioContext来解码和播放音频而不是直接塞audio标签。原因有两点AudioContext.decodeAudioData能兼容各种编码的音频数据尤其是在流式传输的场景下能精确控制播放节奏比如暂停、恢复、跳到下一句核心实现两个模块预合成队列enqueueSpeech把文本放入待合成列表同时持续从后端拉取音频一次只拉取一个句子。前端要限制在3个句子的并发量避免TTS响应太快导致内存溢出。播放队列AudioBufferSourceNode依次播放。当前一句播放完成自动接下一句。简化后的核心逻辑async function playQueue() { if (isPlaying) return isPlaying true while (speechQueue.length 0) { const buffer speechQueue.shift() await playBuffer(buffer) } isPlaying false } function playBuffer(buffer) { return new Promise(resolve { const source audioContext.createBufferSource() source.buffer buffer source.connect(audioContext.destination) source.onended resolve source.start() }) }这里有个关键的Web API兼容性问题AudioBuffer里的copyToChannel方法在Chrome较新版本中已经标记为过时deprecated但很多旧项目还在用。如果你发现某些Android WebView上声音断断续续或完全无声优先检查是不是用了过时的API。另外移动端至少在AudioContext创建时状态是suspended任何播放操作前必须await audioContext.resume()否则不会出声音。这类坑我在第5节里集中讲。4. 不得不说的离线部署与性能优化4.1 模型量化和体积控制TTS模型动辄几百MB对移动端不友好。我实际测试了几个方案的体积和效果对比方案模型大小首句延迟CPU听感评分1-5适用场景vits-cmn-aishell3 fp32约607MB约0.25秒4服务器部署vits-cmn-aishell3 int8量化约367MB约0.2秒3.5中高端安卓/iOSmatcha-icefall-zh-baker约280MB约0.1秒4.5服务器高并发kokoro多语言模型约90MBfp32约0.12秒4端侧离线必选注意不是所有模型都能直接量化。vits模型量化后偶发爆破音我建议int8量化后留一组原始fp32做AB测试尤其要注意音量包络不明显失真。实际项目里我用的是fp32版服务端、int8版客户端效果差距可以接受。4.2 预热与并发控制我踩过一个大坑服务刚启动时第一次请求特别慢要3到4秒才能返回。原因是ONNX Runtime首次推理时要加载模型、分配内存、完成图优化。解决方法是服务启动时预热app.on_event(startup) def warm_up(): tts.synthesize(预热, sid0)服务启动时合成一个不超过10个字的短句把模型加载到内存中后续请求就能稳定在几百毫秒。同时注意sherpa_onnx.OfflineTts在单个实例上是线程安全的我直接用FastAPI的默认线程池处理即可。但建议用run_in_executor把合成任务丢到进程池避免GIL干扰。最初只用线程池时并发超过8个请求CPU直接被打满后来改成进程池后稳定多了。4.3 前端点击播放的体验优化实际使用中用户有时候只想听某一句回复而不是整篇朗读。我在每条消息上放了点击播放该句的按钮。实现上做了两个优化对每个句子进行缓存key是消息ID_句子序号避免重复合成用户点击时优先检查本地缓存再决定是否请求后端从后端合成到返回WAV最快大约400毫秒加上前端解码耗时总在1秒内。如果要彻底去掉这400毫秒另一个方案是让前端WebWorker也加载一个小型TTS模型做边点边合成。代价是App包体要增加约20MB到50MB对包体敏感的项目要权衡。我这里偷了个懒只在后端合成效果已经能接受。4.4 并发合成与进程池我在实际压测中发现单个OfflineTts实例在8并发下CPU占用率会飙到90%以上响应时间也会拉长。解决办法是不共享单个TTS实例而是用进程池每个进程各持有一个TTS实例进程数设为CPU核心数。这样每个进程独立加载模型内存占用虽然翻倍但换来的是稳定的并发响应。我在部署时用了4核机器起了4个worker进程每个进程加载一次模型实测稳定支撑线上30个并发会话。5. 踩坑记录三个影响体验的细节问题5.1 sherpa-onnx模型路径带中文导致初始化失败第一次跑集成测试我把模型放在了D:\项目\语音模型\目录下结果sherpa-onnx初始化直接抛异常。排查了一圈最终发现它内部调用的Jieba分词工具需要加载dict目录下的词典文件而词典路径包含中文时C层的文件读取会出问题。这类问题在Windows的Python环境里尤其常见Linux上反而不容易遇到。解决方案模型路径全部改成纯英文比如/models/vits-cmn-aishell3/。如果你必须用中文路径可以先复制模型到一个临时英文目录初始化完成后再删除。额外注意模型文件不要放在国内网盘同步目录比如OneDrive、坚果云下否则路径经常会被自动加前缀导致初始化失败。5.2 长文本的TTS分割策略别只依赖标点我最初用的是正则直接按标点断句遇到用户连续提问今天天气怎么样明天会下雨吗周末适合爬山吗这种无标点长句TTS会一次性合成一段很长的音频中间没有停顿。用户反馈听起来糊成一团语气也不对。核心解法递归分割加长度上限。先按标点切如果某句仍然超过30个字就继续用逗号、顿号切切完的单句长度控制在20到30字之间。这能避免两个问题一是TTS对过长文本的合成质量下降二是前端播放队列里出现超大WAV导致内存峰值过高。同时如果切出来的句子本身语义不完整比如只是从句的开头可以合并到下一句宁可按语义边界适度加长也不要切出破碎小句。为此我加了一个基于关键连接词的合并规则当前句以但是而且因为等引导词开头时与上一句合并避免听感突兀。5.3 移动端AudioContext播放的第一个字被吞在Android WebView上测试经常出现第一个句话的第一个字听不见的问题。排查过程让我记忆犹新。先怀疑TTS模型丢了采样点后端直接播放WAV文件是好的再去查前端逻辑猜测是解码截断但解码后直接播放也没问题。最后锁定了播放链路的时序AudioContext初始状态是suspended必须调用resume()后才能播放。我在playBuffer里做了resume()但它是一个异步操作而我在resume()还没完成时就开始source.start()导致起始的几十毫秒音频被跳过。解决办法是resume()完再start()并且把解码和播放分离await audioContext.resume() source.start()后来我又遇到一种情况用户从后台切回App后AudioContext会被系统挂起此时需要重新resume()。这里我总结了一条经验写一个统一的ensureAudioContext()工具函数在每次UI交互点击播放、切换页面、收到新语音时都调用一次确保音频上下文始终处于运行状态这是移动端TTS播放最重要的兜底逻辑。6. 扩展思考TTS在AI对话App里的下一步还能做什么6.1 加入语音识别ASR做成完整的语音对话闭环当前版本是文字输入、语音输出。如果想做成语音唤醒、语音对话、语音回复的完整闭环需要引入ASR模块。sherpa-onnx提供了OnlineRecognizer和OfflineRecognizer分别对应流式识别和非流式识别。加上库里的VAD模型可以实现检测到人声才开始录音说完自动结束体验会比微信语音那种按住说话更自然。这套VAD在不同的噪声环境下表现差异很大建议在正式环境里做多场景的噪音采样测试尤其是地铁、咖啡厅、车内这类高频场景。6.2 多音色动态切换配合AI Agent的角色扮演sherpa-onnx的TTS支持sid参数我测试aishell3模型的时候发现它可以输出多个说话人。这意味着你的AI对话App可以实现用户选择不同的虚拟角色角色语音不同的效果而且切换只需要一个整数参数不用加载多套模型。我做了一个小实验让同一个问题用两个不同sid的音频来回复用户试听后明显觉得不同角色在用不同的声音说话沉浸感提升很明显。如果你的Agent系统里有性格/情感设定这会是一个成本极低但体验提升极大的功能点。6.3 多AI协作场景让Agent会商量再做回答最近流行的多AI协作概念在语音对话里也有应用空间主Agent负责理解用户需求辅助Agent负责生成结构化草稿。最终由TTS输出时不同Agent的回复片段可以分配不同音色用户能听出来谁在说话。这种设计在多人会议总结、角色扮演、教学场景里很有价值。注意多Agent协作的回复在拼接时要给TTS喂入完整的标点文本避免多个Agent片段边界处的断句混乱。6.4 音色克隆与个性化语音如果你的产品需要用户录制自己的声音生成专属语音包现在也有一些开源方案可以跑通。但音色克隆涉及用户生物特征合规上要特别慎重上线前做好用户授权协议、数据删除通道、滥用监控。技术上的坑集中在数据清洗不干净导致合成声音吃字、以及跨设备音色偏移两方面建议小规模内测后逐步放开。7. 部署与监控的实操记录这部分容易被忽略但线上语音服务的稳定性往往决定用户是否长期使用。7.1 服务监控TTS服务除了常规的CPU、内存监控我最看重两个指标平均合成时长正常应该在200到500毫秒。如果超过1秒说明模型该优化了换更小的模型或量化或者服务并发已经到瓶颈合成失败率TTS模型极少失败一旦出现多半是输入文本里面有非法字符比如某些表情符号、极端罕见的Unicode。我做了兜底合成失败时前端自动降级为无语音模式只展示文字不让用户完全不可用7.2 文本安全的预处理大模型生成的回复有时候会包含无法被TTS正确朗读的内容比如英文缩写、URL链接、Markdown符号我加了预处理管道先去掉Markdown再把URL统一替换成链接把英文缩写逐个字母读出。这一步是在写正文之后、送入TTS之前完成的。实测发现不做预处理的合成音频在遇到URL时读音完全没法听预处理后基本稳定。预处理的代码只有几十行但带来的体验提升非常明显。7.3 部署形态的取舍如果你的项目主要是Web端可以后端合成、前端播放如果是移动App建议把TTS模型打进App内部完全离线。折中方案是在线时用服务器合成断网时自动降级到端侧离线合成。sherpa-onnx的Android和iOS SDK都能做到这一点代码逻辑上是切换一个引擎实例的事。离线模型体积是绕不过去的坎但换来的是打开App就说话、无网络也流畅的体验。我的经验是如果你的核心用户场景是通勤路上、地铁上信号差这个取舍绝对值得。8. 写在最后的一点个人体会这套方案从立项到稳定跑起来前后大概用了三周。最大的体会是TTS本身不是项目里最难的部分真正花时间的都在那些最后一公里标点切句的策略、前端播放的时序、移动端后台挂起的处理、文本预处理管道。sherpa-onnx让我省去了训练模型的负担让我能把精力放在应用层的体验打磨上。如果你也想做AI对话App的语音输出我的建议是直接跳过云厂商TTS即使不做端侧离线自建一个TTS微服务成本也低得多而且在延迟和故障排查上主动权都在自己手里。后续如果要加语音输入再在sherpa-onnx里把ASR和VAD接进来整套架构完全不用推倒重来。我自己下一步的计划是给这套TTS服务加一个角色音色管理模块让多角色对话的语音能自动拼接成一段连贯音频如果你也在做语音对话产品这个方向值得提前规划。

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

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

免费获取报价 →
↑