Files
sundynix-agentix/sundynix-gateway/internal/voice/sentence_buffer.go
T
Blizzard 644b635d73 feat(voice): 攒句器——语音下行 TTS 的 token 攒句(JARVIS 地基第一块)
语音交互(VOICE_DESIGN.md)不依赖火山账号的第一块地基:SentenceBuffer 把 LLM 逐 token
输出攒成'适合喂 TTS 的句子片段'。逐字喂 TTS 太碎(单字合成不自然、首包延迟高);句末标点
(。!?.!?;;换行)即成一句,从句标点(,,::)且攒够 12 rune 也吐(让长回答尽早出声)。
跨 Push 续半句,Flush 收尾吐无标点结尾。纯逻辑无外部依赖、4 个单测(跨 Push/短从句不断/
长从句先吐/Flush 收尾)。

WS 会话 + 火山 ASR/TTS 客户端 + 控制面配置等到真 API 参数到手再按真形状做,不写猜测桩。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:30:50 +08:00

71 lines
2.3 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
// 句末标点:命中即成一句吐给 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
}
// NewSentenceBuffer 建一个默认阈值的攒句器。
func NewSentenceBuffer() *SentenceBuffer { return &SentenceBuffer{minClause: defaultMinClause} }
// 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.minClause {
cut = true
}
if cut {
if s := strings.TrimSpace(string(runes[lastCut : i+1])); s != "" {
out = append(out, s)
}
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
}