^-^
纯本地实时语音对话系统:ASR + LLM + TTS 全流程落地
最近更新:2026-09-05   |   字数总计:1.7k   |   阅读估时:6分钟   |   阅读量:
Note
本文由 AI 依据开发手记草稿使用 write-blog skill 自动生成

最近一周下班后,用 ClaudeCode CLI + DeepSeek-v4-flash 全程 vibe coding 搓了一个实时语音对话系统:麦克风进、AI 语音回复出,ASR + LLM + TTS 全流程跑在本地,零云服务。代码拆成三个独立子项目,各带可插拔后端:

  • voice0(TTS):实现 melo + CosyVoice2(支持零样本音色克隆)。CosyVoice2 是自回归 LLM 式 TTS,本机(RTX 2070 SUPER 8GB)RTF≥1(合成速度慢于播放实时速率),无法实现完美的流式实时输出,会出现播放卡顿;melo 是非自回归模型,一句一次前向推理、速度快,是主力
  • voice1(ASR + 对话系统编排):实现 paraformer、whisper 等多种后端引擎。其中流式 paraformer 以非自回归并行解码 + 流式增量识别,兼顾了低延迟与最佳识别准确率,是语音对话的主力识别引擎
  • llmx(本地 LLM):llama.cpp + GGUF,支持 Qwen2.5 / Qwen3,8GB 显卡轻松可跑 Qwen3-4B。对话系统对 LLM 能力要求不高,本地部署既免费又完全离线

语音对话系统入口在 voice1 项目的 voice_dialogue.py。三个项目的模型服务、按两种方式调用:

  • LLM = 进程外服务化调用:OpenAI 兼容 HTTP(/v1/chat/completions + SSE 流式),LLM 跑在独立进程(本地 llmx 服务或云端 DeepSeek),voice1 通过 HTTP 请求与LLM进行进程间通信。可先接在线 DeepSeek API 测试,再切本地 llmx,只需改 dialogue/config.local.json 三行配置即可,详见使用文档
  • ASR / TTS = 进程内嵌入:模型以 Python 库形式直接import加载进语音对话进程(python examples/voice_dialogue.py),由后端抽象层(Backend ABC + 模型注册表)统一调度(调用是函数级的,无进程间通信),与编排代码同进程协作、共享显存

对话系统支持自定义唤醒词唤醒,可通过指定关键词休眠,或 idletime 超时无对话活动自行休眠,支持指定用户系统提示词,并且设计为实时可打断:

  • 单进程全链路非阻塞:主线程只采麦克风,ASR / LLM / TTS 各自在独立线程并行推进
  • LLM 流式输出按句切分(根据标点符号如”。!?…;\n进行切分),逐句入队交给 TTS 串行合成,TTS边说、LLM边吐出下个句子,句间音频输出无缝
  • AI 播放时半双工门控,说「停下」立即掐断(LLM 流式断连即停 + TTS 队列打断音频输出),人机对话不迟滞硬排队
  • 自然打断不排队:AI 回答中你说话 → 立即断流、把本轮累计连同新句重发重答;AI 已答完但音频未开播的窗口内补句 → 撤回刚答的连同历史重答

三个项目同级别目录下git clone放置,可各自独立安装跑通,若使用本地 llm 服务,需要在 llmx 项目目录下先执行 run_server.bat --model qwen3-4b,之后在 voice1 项目目录下即可命令行启动聊天对话系统:

1
python examples/voice_dialogue.py --asr-device cuda --tts-device cuda --vad-tail 300 --vad-threshold-db -42 --system-prompt dialogue/user_prompt.txt --tts-normalize rms --llm-config dialogue/config.local.json

项目架构通览(mermaid):

sequenceDiagram
    autonumber
    participant MIC as 主线程
PortAudio回调
每~20ms一块 participant ASR as ASR worker
voice1 participant CTL as DialogueController participant LLM as LLM线程
dialogue-llm participant TTS as TTS worker
voice0 queue Note over MIC,TTS: 全链路非阻塞:主线程只采麦克风,各段在各自线程干活 rect rgb(238,244,255) Note over MIC,ASR: ① 采集→断句→识别(voice1) loop 持续(说话/静音都喂) MIC->>ASR: asr.ingest(mono)
非阻塞入队(满则背压阻塞) ASR->>ASR: 能量VAD:静音尾≥vad-tail
→判定"这句说完了" ASR->>ASR: paraformer识别
(流式cache + 句末flush定稿) end ASR-->>MIC: on_partial → "…出字"(未定稿,不进LLM) ASR-->>CTL: on_sentence(定稿句)
(ASR worker线程回调) end rect rgb(255,248,230) Note over CTL,LLM: ② 提交LLM(controller快操作,不阻塞识别) CTL->>CTL: feed_asr_sentence():累加本轮
gen+=1 · 在途/post-commit检查 CTL->>LLM: 启动 _llm_loop 线程(非阻塞) CTL-->>MIC: on_user → 控制台时间戳定稿行 end rect rgb(235,250,235) Note over LLM,TTS: ③ LLM流式输出→按句切分→TTS串行合成播放 LLM->>LLM: stream_chat() 阻塞读SSE
(专用线程,不卡主线程) loop 每个token增量 LLM-->>CTL: on_ai_delta → 控制台"AI: …"流式原地刷新 CTL->>CTL: _emit_sentences():按 。!? 切句
(逗号不切;40字硬切兜底) CTL->>TTS: tts.submit(句)(非阻塞入队) TTS->>TTS: melo合成(~0.4s) + 播放(queue串行) end LLM->>CTL: 流结束:flush残句 + 记录usage
commit(user→assistant)进历史 end rect rgb(252,240,246) Note over MIC,CTL: ④ 打断与回声门控(半双工) Note over CTL: 新定稿句 → 三种情况: Note over CTL: ·LLM在途 → gen+1弃流 + tts.interrupt() → 累计重发 Note over CTL: ·已答完、音频未开播(post-commit窗口内)→ 撤答复合并重发 Note over CTL: ·过窗口(音频已开播)→ 新轮,不打断语音 Note over MIC: 回声门控:mic回调读 ctrl.tts_busy
播放期只喂 ingest_kws_only(听"停下")
滚动grace:开播后 echo-guard 内
有语音能量 → 仍喂正常识别(抓续句尾巴) end

PS:以上项目总计消耗 deepseek 4.2亿 token,总花费 61 元

后续更新:

[26.9.2] TTS 播放支持联动 live2d(可选项,进程外服务化调用,启动语音对话系统需要前先启动 live2d server:启动命令),随语音播放自动发送心态表情 + 头顶说话框(说话框文本逐句链式跟播)至 live2d server,嘴型由 live2d 服务端监听播放音频自行对口型
[26.9.5] 之前我们的对话系统是直接接入 LLM 大模型(ASR->LLM->TTS),不具备工具调用能力,连询问今天是几月几号都不知道,因此最新实现支持接入本地 claudecode agent(ASR->AGENT->TTS),可随时自定义 skill、mcp 扩展,譬如添加天气获取的能力等,缺点是回复延迟稍大一丢丢(因为涉及多轮工具调用和 LLM 推理)。最新一键启动语音对话系统命令:start_dialogue.bat [llm|agent]

demo示例(此处演示是接入的 LLM 且调用在线 DeepSeek API):


本文作者:muggledy
版权声明:本博客所有文章除特别声明外,均采用知识共享 署名—非商业性使用—禁止演绎 4.0 国际许可协议 © CC BY-NC-ND 4.0 进行许可。非商用转载请注明出处!严禁商业转载!
本文链接https://blog.muggledy.top/posts/104e1f98/