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>
This commit is contained in:
Blizzard
2026-07-21 16:30:50 +08:00
parent f16f63284f
commit 644b635d73
2 changed files with 132 additions and 0 deletions
@@ -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}
}