b6927247da
- 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>
85 lines
2.9 KiB
Go
85 lines
2.9 KiB
Go
// Package voice 是语音交互(JARVIS)的网关内实现:把 LLM token 流转成语音、把用户语音转成任务。
|
||
// 设计见仓库 VOICE_DESIGN.md。本文件是下行 TTS 的核心:token 流攒句器(纯逻辑,无外部依赖)。
|
||
package voice
|
||
|
||
import "strings"
|
||
|
||
// 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。
|
||
const defaultMinClause = 12
|
||
|
||
// 首块阈值:本轮**第一次**出声用更低的门槛,让首字尽快合成、尽快听见(抢首字延迟)。
|
||
// 之后回到 defaultMinClause 保后续朗读顺畅、不碎。
|
||
const firstClauseMin = 5
|
||
|
||
// 句末标点:命中即成一句吐给 TTS。
|
||
func isSentenceEnd(r rune) bool {
|
||
switch r {
|
||
case '。', '!', '?', '.', '!', '?', '\n', ';', ';':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 从句标点:命中且已攒够长度才断句(让长句尽早出声,又不至于碎成单字)。
|
||
func isClauseEnd(r rune) bool {
|
||
switch r {
|
||
case ',', ',', ':', ':':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// SentenceBuffer 把 LLM 的逐 token 输出攒成"适合喂 TTS 的句子片段"。
|
||
// 逐字喂 TTS 太碎(单字合成不自然、首包延迟高);攒到句末标点即吐一句;攒到从句标点且够长也吐,
|
||
// 避免长句迟迟不出声。非并发安全——单个 VoiceSession 的下行 goroutine 串行使用。
|
||
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 {
|
||
b.buf.WriteString(text)
|
||
runes := []rune(b.buf.String())
|
||
var out []string
|
||
lastCut := 0
|
||
for i, r := range runes {
|
||
cut := false
|
||
if isSentenceEnd(r) {
|
||
cut = true
|
||
} 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
|
||
}
|
||
}
|
||
b.buf.Reset()
|
||
b.buf.WriteString(string(runes[lastCut:])) // 留下未成句的尾巴
|
||
return out
|
||
}
|
||
|
||
// Flush 收尾:吐出缓冲里剩余的所有文字(可能不带句末标点,如模型直接结束)。空则返回空串。
|
||
func (b *SentenceBuffer) Flush() string {
|
||
s := strings.TrimSpace(b.buf.String())
|
||
b.buf.Reset()
|
||
return s
|
||
}
|