// 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 }