diff --git a/sundynix-desktop/frontend/src/lib/voice.ts b/sundynix-desktop/frontend/src/lib/voice.ts index 3e35011..e0775dc 100644 --- a/sundynix-desktop/frontend/src/lib/voice.ts +++ b/sundynix-desktop/frontend/src/lib/voice.ts @@ -14,6 +14,7 @@ export interface VoiceCallbacks { onState?: (s: VoiceState) => void; onTranscript?: (text: string, final: boolean) => void; onTask?: (taskId: string) => void; + onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token) onError?: (msg: string) => void; } @@ -115,6 +116,9 @@ export class VoiceClient { case "task": if (m.task_id) this.cb.onTask?.(m.task_id); break; + case "reply": + if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本 + break; case "speaking": this.setState("speaking"); break; diff --git a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx index 65d9df0..f68d271 100644 --- a/sundynix-desktop/frontend/src/shell/VoiceDock.tsx +++ b/sundynix-desktop/frontend/src/shell/VoiceDock.tsx @@ -24,8 +24,9 @@ export function VoiceDock({ onTask }: Props) { const toast = useToast(); const clientRef = useRef(null); const [state, setState] = useState("idle"); - const [transcript, setTranscript] = useState(""); - const [open, setOpen] = useState(false); // 是否展开转写气泡 + const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写) + const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加) + const [open, setOpen] = useState(false); // 是否展开对话气泡 // 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。 const ensureClient = useCallback((): VoiceClient => { @@ -38,7 +39,11 @@ export function VoiceDock({ onTask }: Props) { }, onTask: (taskId) => { onTask(taskId); - setOpen(false); + setOpen(true); // 留着气泡显示打字机回答 + }, + onReply: (delta) => { + setReply((r) => r + delta); // 打字机:增量拼接,LLM 首 token 即刻可见 + setOpen(true); }, onError: (msg) => toast.push("error", msg), }); @@ -55,6 +60,7 @@ export function VoiceDock({ onTask }: Props) { c.stopListening(); } else { setTranscript(""); + setReply(""); // 新一轮:清上一轮的回答 setOpen(true); await c.startListening(); // speaking 中会先打断再开新一轮 } @@ -68,11 +74,25 @@ export function VoiceDock({ onTask }: Props) { return (
- {/* 转写气泡 */} - {open && transcript && ( -
+ {/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */} + {open && (transcript || reply) && ( +
- {transcript} +
+ {transcript && ( +

+ + {transcript} +

+ )} + {reply && ( +

+ JARVIS + {reply} + {state === "thinking" && } +

+ )} +
diff --git a/sundynix-gateway/cmd/voicesim/main.go b/sundynix-gateway/cmd/voicesim/main.go index ee480a9..78362ca 100644 --- a/sundynix-gateway/cmd/voicesim/main.go +++ b/sundynix-gateway/cmd/voicesim/main.go @@ -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", diff --git a/sundynix-gateway/internal/handler/voice_tts.go b/sundynix-gateway/internal/handler/voice_tts.go index 19185b5..ebc20fc 100644 --- a/sundynix-gateway/internal/handler/voice_tts.go +++ b/sundynix-gateway/internal/handler/voice_tts.go @@ -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) } diff --git a/sundynix-gateway/internal/voice/protocol.go b/sundynix-gateway/internal/voice/protocol.go index 2198ead..f41bd77 100644 --- a/sundynix-gateway/internal/voice/protocol.go +++ b/sundynix-gateway/internal/voice/protocol.go @@ -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" // 出错 diff --git a/sundynix-gateway/internal/voice/sentence_buffer.go b/sundynix-gateway/internal/voice/sentence_buffer.go index 1c19a21..dad4569 100644 --- a/sundynix-gateway/internal/voice/sentence_buffer.go +++ b/sundynix-gateway/internal/voice/sentence_buffer.go @@ -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 } diff --git a/sundynix-gateway/sim_answer.wav b/sundynix-gateway/sim_answer.wav index 495a601..40f0d15 100644 Binary files a/sundynix-gateway/sim_answer.wav and b/sundynix-gateway/sim_answer.wav differ diff --git a/sundynix-gateway/sim_question.wav b/sundynix-gateway/sim_question.wav index e564474..e31409b 100644 Binary files a/sundynix-gateway/sim_question.wav and b/sundynix-gateway/sim_question.wav differ