feat(voice): 下行打字机文本流 + 首块快出,即时反馈
- protocol.go: 新增 ServerReply("reply") 消息——Agent 回答增量文本
- voice_tts.go: token 一到即转发客户端(打字机),同时攒句喂 TTS(音频随后)
- sentence_buffer.go: 本轮首句用低阈值(5 rune)抢首字延迟,之后回常规 12
- 桌面端 voice.ts onReply + VoiceDock 对话气泡(我说的 + JARVIS 打字机回答,思考态光标)
管线已最优:文字在 LLM 首 token 即刻上屏、音频紧随。剩余时延=大模型 TTFT(deepseek-v4-pro
4-7s 且波动大,疑似推理模型),这是模型的账、非管线——真要"马上响应"需换快模型。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,8 @@ func main() {
|
||||
|
||||
answer := make([]byte, 0, 1<<20)
|
||||
done := make(chan struct{})
|
||||
var endAt, tTask, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
|
||||
var endAt, tTask, tFirstReply, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
|
||||
var replyText string
|
||||
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
|
||||
defer close(done)
|
||||
for {
|
||||
@@ -100,6 +101,12 @@ func main() {
|
||||
case voice.ServerTask:
|
||||
tTask = time.Now()
|
||||
fmt.Printf(" ← 任务已提交 task_id=%s(Agent 正在思考…)\n", m.TaskID)
|
||||
case voice.ServerReply:
|
||||
if tFirstReply.IsZero() {
|
||||
tFirstReply = time.Now()
|
||||
fmt.Println(" ← 💬 打字机开始(回答文字逐字冒出)…")
|
||||
}
|
||||
replyText += m.Text
|
||||
case voice.ServerSpeaking:
|
||||
fmt.Println(" ← Agent 开始朗读回答…")
|
||||
case voice.ServerTTSEnd:
|
||||
@@ -141,13 +148,16 @@ func main() {
|
||||
fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur)
|
||||
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
|
||||
|
||||
// 时延拆解(以"说完"为 0 点)。首字延迟 = 听到第一声的时间,是体感关键。
|
||||
// 时延拆解(以"说完"为 0 点)。
|
||||
fmt.Println("\n⏱️ 时延拆解(从「说完」起算):")
|
||||
if !tTask.IsZero() {
|
||||
fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds())
|
||||
}
|
||||
if !tFirstReply.IsZero() {
|
||||
fmt.Printf(" · 💬 首字上屏 %.2fs ← 打字机开始(体感「马上响应」就看这个)\n", tFirstReply.Sub(endAt).Seconds())
|
||||
}
|
||||
if !tFirstAudio.IsZero() {
|
||||
fmt.Printf(" · 👂 首字出声 %.2fs ← 体感响应速度就看这个\n", tFirstAudio.Sub(endAt).Seconds())
|
||||
fmt.Printf(" · 👂 首字出声 %.2fs ← 听到第一声\n", tFirstAudio.Sub(endAt).Seconds())
|
||||
}
|
||||
if !tDone.IsZero() {
|
||||
fmt.Printf(" · 朗读完毕 %.2fs(= 首字 %.2fs + 念完 %.1fs 那段话)\n",
|
||||
|
||||
@@ -39,6 +39,9 @@ func (s *voiceSession) speak(taskID string) {
|
||||
|
||||
unsub, err := s.h.bus.SubscribeTokens(taskID,
|
||||
func(tok []byte) {
|
||||
// 打字机:每个 token 一到就转发给客户端显示(早于音频,LLM 首 token 即刻反馈)。
|
||||
s.send(voice.ServerMsg{Type: voice.ServerReply, Text: string(tok)})
|
||||
// 同时攒句喂 TTS(成句即合成,音频随后跟上)。
|
||||
for _, sentence := range sb.Push(string(tok)) {
|
||||
push(sentence)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
type ServerMsg struct {
|
||||
Type string `json:"type"`
|
||||
// transcript:ASR 转写(Final=false 为实时部分结果,true 为最终)
|
||||
// reply:Agent 回答的增量文本(打字机效果,逐 token 下发,早于音频)
|
||||
Text string `json:"text,omitempty"`
|
||||
Final bool `json:"final,omitempty"`
|
||||
// task:转写完成、任务已提交,带 task_id 供客户端切运行视图
|
||||
@@ -41,6 +42,7 @@ const (
|
||||
ServerReady = "ready" // 会话就绪,可以开始说话
|
||||
ServerTranscript = "transcript" // ASR 转写结果(部分/最终)
|
||||
ServerTask = "task" // 任务已提交(带 task_id)
|
||||
ServerReply = "reply" // Agent 回答增量文本(打字机;逐 token,早于音频)
|
||||
ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至)
|
||||
ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕
|
||||
ServerError = "error" // 出错
|
||||
|
||||
@@ -7,6 +7,10 @@ import "strings"
|
||||
// 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。
|
||||
const defaultMinClause = 12
|
||||
|
||||
// 首块阈值:本轮**第一次**出声用更低的门槛,让首字尽快合成、尽快听见(抢首字延迟)。
|
||||
// 之后回到 defaultMinClause 保后续朗读顺畅、不碎。
|
||||
const firstClauseMin = 5
|
||||
|
||||
// 句末标点:命中即成一句吐给 TTS。
|
||||
func isSentenceEnd(r rune) bool {
|
||||
switch r {
|
||||
@@ -31,11 +35,20 @@ func isClauseEnd(r rune) bool {
|
||||
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 {
|
||||
@@ -47,12 +60,13 @@ func (b *SentenceBuffer) Push(text string) []string {
|
||||
cut := false
|
||||
if isSentenceEnd(r) {
|
||||
cut = true
|
||||
} else if isClauseEnd(r) && (i+1-lastCut) >= b.minClause {
|
||||
} 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
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user