本节摘要:零件齐了:7.1 的录音加转写、6.2 的本地对话 API、再加一个可选的 TTS。本节把它们焊成完整回路——按一下说话,几秒后听到回答。先给回路图与各环节职责(转写是耳朵、llama-server 是大脑、TTS 是可选的嘴);然后是代码骨架:一个主循环串起 record → transcribe → ask → speak 四个函数(写法示意);最后是实时性预算表——从按下按钮到听见回答,每个环节花几秒、超支了砍哪里(转写降档、生成减 token、TTS 换轻引擎或关闭)。闭环跑通的那一刻,你就拥有了一个本地版"能听会说的小 ChatGPT"——nanochat 四阶段口径的推理界面至此完整。
阅读完本节,你应当能够:
┌──────────────────────── 语音对话闭环 ────────────────────────┐ │ │ │ ① 按下说话(录音) 16kHz 单声道,3~6 秒 [7.1] │ │ │ │ │ ▼ │ │ ② Whisper 转写 声音→文字(档位 tiny/base/small)[7.1] │ │ │ │ │ ▼ │ │ ③ 装进 messages 文字 + 历史 → 第 2 章契约 │ │ │ │ │ ▼ │ │ ④ llama-server POST /v1/chat/completions [6.2] │ │ │ │ │ ▼ │ │ ⑤ 回答文本 ──────────► 屏幕显示(保底输出,永不缺席) │ │ │ │ │ ▼(可选) │ │ ⑥ TTS 播放 文字→语音(本地引擎或浏览器合成) │ │ │ └──────────────────────────────────────────────────────────────┘
设计原则两条:文字显示永远保留(TTS 失败或关闭时,闭环降级不瘫痪);messages 历史由主循环维护(6.2 客户端的分工原则原样搬来——客户端只管 messages,不碰模板)。
# voice_chat.py —— 语音对话闭环骨架(写法示意,依赖与接口以各官方 README 为准) import wave import requests import sounddevice as sd import whisper SR, SECS = 16000, 5 URL = "http://127.0.0.1:8080/v1/chat/completions" asr = whisper.load_model("small") # 7.1 档位表 def record() -> str: # ① 录音 → 临时 wav audio = sd.rec(int(SECS * SR), samplerate=SR, channels=1, dtype="int16") sd.wait() with wave.open("data_voice/q.wav", "w") as w: w.setnchannels(1) w.setsampwidth(2) w.setframerate(SR) w.writeframes(audio.tobytes()) return "data_voice/q.wav" def transcribe(path: str) -> str: # ② 转写 → 文字 return asr.transcribe(path, language="zh", fp16=False)["text"].strip() def ask(history: list[dict]) -> str: # ③④ 对话腿 → 回答 r = requests.post(URL, json={ "messages": history, "temperature": 0.5, # 语音场景偏稳(4.2 场景表) "max_tokens": 120, # 语音回答宜短——也省实时预算 }, timeout=60) r.raise_for_status() return r.json()["choices"][0]["message"]["content"] def speak(text: str) -> None: # ⑥ 可选 TTS——换成你的引擎即可(示意) try: import pyttsx3 # 本地离线 TTS 之一,接口以其文档为准 pyttsx3.speak(text) except Exception: print("(TTS 未启用,仅文字)") if __name__ == "__main__": history = [{"role": "system", "content": "你是一个简洁的中文语音助手。"}] while True: input("按回车开始录音…") q = transcribe(record()) if not q: continue # 静音幻觉防线(7.1) print(f"你:{q}") history.append({"role": "user", "content": q}) a = ask(history) history.append({"role": "assistant", "content": a}) print(f"助手:{a}") speak(a)
骨架刻意保持"每环节一个函数、主循环只做编排"——任何一个环节换实现(Whisper 换 whisper.cpp、TTS 换云端音色、6.2 换别的服务)都不惊动其他环节。
从按下回车到听见回答(示意估算,CPU 为普通笔记本、GPU 为单张消费级显卡;实测随硬件浮动):
| 环节 | CPU 预算 | GPU 预算 | 超支砍哪里 |
|---|---|---|---|
| 录音 | 固定 3~6 秒 | 同左 | 缩短录音时长 |
| Whisper small 转写 | 2~6 秒 | <1 秒 | 降档 base/tiny(7.1 表) |
| 124M Q8_0 生成 120 token | 2~8 秒 | 1~2 秒 | 减 max_tokens;升量化档(6.1 表) |
| TTS 合成播放 | 1~3 秒 | 同左 | 换轻引擎或关闭 |
| 合计目标 | ≤10 秒 | ≤4 秒 | — |
联动结论:预算表把前面所有选档决策串起来——转写档(7.1)、量化档(6.1)、生成参数(4.2)不再各选各的,而是共同对"合计目标"负责。这也是 0.2"先小后大"纪律的另一种说法:每个环节都用最小够用的档位,加起来才是快。
骨架到产品之间还有一程,本书只留路标(均以各官方仓库 README 为准):VAD 自动断句(检测说话结束,替代按回车);流式转写与流式生成(边说边转、边生成边播);whisper.cpp(llama.cpp 姊妹项目,CPU 高效跑 Whisper 的路线,与第 6 章同生态);打断处理(用户开口即停播)。这些都不改变回路的骨架,只替换环节实现。
能听会说的本地小 ChatGPT 成形——最后一程是改造实验:让这套流水线说好中文(8.1)、探一探小模型的能力边界(8.2),再到对齐训练门前看一眼路标(8.3)。