feat(voice): 下行打字机文本流 + 首块快出,即时反馈

- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)

管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-22 11:25:20 +08:00
parent 6b31856551
commit b6927247da
8 changed files with 64 additions and 11 deletions
+13 -3
View File
@@ -69,7 +69,8 @@ func main() {
answer := make([]byte, 0, 1<<20)
done := make(chan struct{})
var endAt, tTask, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
var endAt, tTask, tFirstReply, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
var replyText string
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
defer close(done)
for {
@@ -100,6 +101,12 @@ func main() {
case voice.ServerTask:
tTask = time.Now()
fmt.Printf(" ← 任务已提交 task_id=%sAgent 正在思考…)\n", m.TaskID)
case voice.ServerReply:
if tFirstReply.IsZero() {
tFirstReply = time.Now()
fmt.Println(" ← 💬 打字机开始(回答文字逐字冒出)…")
}
replyText += m.Text
case voice.ServerSpeaking:
fmt.Println(" ← Agent 开始朗读回答…")
case voice.ServerTTSEnd:
@@ -141,13 +148,16 @@ func main() {
fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur)
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
// 时延拆解(以"说完"为 0 点)。首字延迟 = 听到第一声的时间,是体感关键。
// 时延拆解(以"说完"为 0 点)。
fmt.Println("\n⏱️ 时延拆解(从「说完」起算):")
if !tTask.IsZero() {
fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds())
}
if !tFirstReply.IsZero() {
fmt.Printf(" · 💬 首字上屏 %.2fs ← 打字机开始(体感「马上响应」就看这个)\n", tFirstReply.Sub(endAt).Seconds())
}
if !tFirstAudio.IsZero() {
fmt.Printf(" · 👂 首字出声 %.2fs ← 体感响应速度就看这个\n", tFirstAudio.Sub(endAt).Seconds())
fmt.Printf(" · 👂 首字出声 %.2fs ← 听到第一声\n", tFirstAudio.Sub(endAt).Seconds())
}
if !tDone.IsZero() {
fmt.Printf(" · 朗读完毕 %.2fs= 首字 %.2fs + 念完 %.1fs 那段话)\n",