diff --git a/sundynix-gateway/internal/voice/sentence_buffer.go b/sundynix-gateway/internal/voice/sentence_buffer.go new file mode 100644 index 0000000..1c19a21 --- /dev/null +++ b/sundynix-gateway/internal/voice/sentence_buffer.go @@ -0,0 +1,70 @@ +// 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 +} diff --git a/sundynix-gateway/internal/voice/sentence_buffer_test.go b/sundynix-gateway/internal/voice/sentence_buffer_test.go new file mode 100644 index 0000000..47fc2fd --- /dev/null +++ b/sundynix-gateway/internal/voice/sentence_buffer_test.go @@ -0,0 +1,62 @@ +package voice + +import ( + "reflect" + "testing" +) + +// 逐 token 喂入,句末标点成句、跨 Push 的半句能续上。 +func TestSentenceBuffer_SplitsAcrossPushes(t *testing.T) { + b := NewSentenceBuffer() + var got []string + // 模拟 token 流一个字一个字来 + for _, tok := range []string{"帮", "你", "查", "一下", "天气", "。", "今天", "晴", ",", "气温二十五度", "。"} { + got = append(got, b.Push(tok)...) + } + got = append(got, nonEmpty(b.Flush())...) + want := []string{"帮你查一下天气。", "今天晴,气温二十五度。"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +// 从句标点:够长才断(短逗号不单吐,攒进句末标点成一句)。 +func TestSentenceBuffer_ShortClauseNotSplit(t *testing.T) { + b := NewSentenceBuffer() + out := b.Push("你好,") // 3 rune < 12 阈值,不该断 + if len(out) != 0 { + t.Fatalf("短从句不该断句,得 %v", out) + } + out = append(out, b.Push("在。")...) // 到句末标点 → 整句吐 + if len(out) != 1 || out[0] != "你好,在。" { + t.Fatalf("短从句应攒到句末再吐,得 %v", out) + } +} + +// 长从句:够长即在逗号处先吐,让长回答尽早出声。 +func TestSentenceBuffer_LongClauseSplits(t *testing.T) { + b := NewSentenceBuffer() + out := b.Push("关于人工智能在医疗领域的应用,") + if len(out) != 1 { + t.Fatalf("长从句应在逗号处先吐一段,得 %v", out) + } +} + +// Flush 收尾:模型没给句末标点也要把剩余吐出。 +func TestSentenceBuffer_FlushRemainder(t *testing.T) { + b := NewSentenceBuffer() + b.Push("这是没有标点的结尾") + if s := b.Flush(); s != "这是没有标点的结尾" { + t.Fatalf("Flush 应吐出剩余,得 %q", s) + } + if s := b.Flush(); s != "" { + t.Fatalf("再 Flush 应为空,得 %q", s) + } +} + +func nonEmpty(s string) []string { + if s == "" { + return nil + } + return []string{s} +}