Files
sundynix-agentix/sundynix-gateway/internal/voice/sentence_buffer.go
T
Blizzard b6927247da 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>
2026-07-22 11:25:20 +08:00

85 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}