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:
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user