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
@@ -7,6 +7,10 @@ import "strings"
// 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。
const defaultMinClause = 12
// 首块阈值:本轮**第一次**出声用更低的门槛,让首字尽快合成、尽快听见(抢首字延迟)。
// 之后回到 defaultMinClause 保后续朗读顺畅、不碎。
const firstClauseMin = 5
// 句末标点:命中即成一句吐给 TTS。
func isSentenceEnd(r rune) bool {
switch r {
@@ -31,11 +35,20 @@ func isClauseEnd(r rune) bool {
type SentenceBuffer struct {
buf strings.Builder
minClause int
emitted bool // 本轮是否已出过第一句(决定用首块低阈值还是常规阈值)
}
// NewSentenceBuffer 建一个默认阈值的攒句器。
func NewSentenceBuffer() *SentenceBuffer { return &SentenceBuffer{minClause: defaultMinClause} }
// clauseThreshold 首句用低阈值抢首字延迟,之后回常规阈值。
func (b *SentenceBuffer) clauseThreshold() int {
if !b.emitted {
return firstClauseMin
}
return b.minClause
}
// Push 追加一段 token 文本,返回本次可以立即吐给 TTS 的完整句子(0..N 句,已 Trim 两端空白)。
// 未成句的尾巴留在内部缓冲,等后续 token 或 Flush。
func (b *SentenceBuffer) Push(text string) []string {
@@ -47,12 +60,13 @@ func (b *SentenceBuffer) Push(text string) []string {
cut := false
if isSentenceEnd(r) {
cut = true
} else if isClauseEnd(r) && (i+1-lastCut) >= b.minClause {
} else if isClauseEnd(r) && (i+1-lastCut) >= b.clauseThreshold() {
cut = true
}
if cut {
if s := strings.TrimSpace(string(runes[lastCut : i+1])); s != "" {
out = append(out, s)
b.emitted = true // 出过一句后,后续回常规阈值(不碎)
}
lastCut = i + 1
}