Files

607 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sundynix-agentix · 语音交互设计文档
> 版本:2026-07-17
> 定位:让用户通过**语音**与 Agent 双向对话——语音命令让 Agent 干活(做任务/写报告/检索知识库),Agent 语音回答结果。
> 配套文档:`ARCHITECTURE_DESIGN.md`(架构总览)、`DEPTH_ROADMAP.md`(路线图)
---
## 1. 目标与范围
### 1.1 要做什么
用户对着麦克风说「帮我写一份关于 AI 医疗的报告」→ 系统实时识别语音 → 自动触发现有 Agent 编排 → Agent 边想边"说"结果给用户听。
**全链路**
```
🎤 用户说话 → ASR(语音→文字) → 现有编排引擎(Eino) → Token 流 → TTS(文字→语音) → 🔊 用户听到
```
### 1.2 核心原则
1. **只加耳朵和嘴巴,不动大脑**——现有 Dispatcher/Eino/工具/RAG/报告 一行不改
2. **复用现有通信管道**——NATS token 流 + SSE 回流原样利用
3. **控制面统一管理**——语音配置(ASR/TTS)走现有的 admin 控制面 + NATS 热更新
4. **可降级**——语音服务不可用时平台文字功能不受影响
### 1.3 不做什么(本期)
- ❌ 语音唤醒 / 声纹识别
- ❌ 多人同时语音会议
- ❌ 视频通话
- ❌ 端到端语音大模型(绕开编排引擎的方案不考虑)
---
## 2. 火山引擎 API 选型
### 2.1 需要开通的服务
| 服务 | 用途 | API 协议 | 文档 |
|---|---|---|---|
| **流式语音识别** | 🎤 用户说话 → 文字 | WebSocket 双向流 | [流式语音识别 WebSocket](https://www.volcengine.com/docs/6561/1354869) |
| **双向流式语音合成 (V3)** | 🔊 文字 → Agent 说话 | WebSocket 双向流 | [双向流式 TTS WebSocket V3](https://www.volcengine.com/docs/6561/1329505) |
### 2.2 为什么选这两个
| 决策 | 原因 |
|---|---|
| **流式 ASR**(非一句话识别) | 用户说长句/多句时能实时出部分结果,体验像实时字幕 |
| **双向流式 TTS**(非 HTTP 非流式) | 可以边喂文字边拿音频,不用等全文生成完;与 token 流天然配合 |
| **不用端到端语音大模型 API** | 那个会绕开 Eino 编排引擎,图编排/工具调用/RAG/报告全废 |
### 2.3 火山引擎配置参数
```
# .env 新增(与现有 LLM 配置同级)
VOLC_ASR_APPID= # 火山引擎 appid
VOLC_ASR_TOKEN= # 火山引擎 access token
VOLC_ASR_CLUSTER= # ASR 集群(如 volcengine_streaming_common
VOLC_TTS_APPID= # 可与 ASR 同一个 appid
VOLC_TTS_TOKEN= # 可与 ASR 同一个 token
VOLC_TTS_CLUSTER= # TTS 集群(如 volcano_tts
VOLC_TTS_VOICE_TYPE= # 音色(如 BV700_streaming
```
---
## 3. 架构设计
### 3.1 整体架构(在现有 5 层上的增量)
```
┌── 客户端层 ─────────────────────────────────────────────────────────┐
│ 桌面端 Wails / 浏览器 │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ 🎤 录音按钮 │ │ 📝 实时转写显示│ │ 🔊 音频播放队列 │ │
│ │ MediaRecorder│ │ (边说边显字) │ │ (边收边播放) │ │
│ └──────┬─────┘ └──────────────┘ └────────▲────────┘ │
│ │ 音频帧上行 音频帧下行 │ │
│ └──────────── WebSocket ────────────┘ │
└────────────────────────┬────────────────────────────────────────────┘
┌── Gateway(接入层)─────┴────────────────────────────────────────────┐
│ │
│ 新端点: GET /api/v1/voice/stream (WebSocket 升级) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ VoiceSession │ │
│ │ │ │
│ │ 上行链路: │ │
│ │ 客户端音频帧 → volcASRClient(WS) → 转写文字 → 推回客户端显示 │ │
│ │ ↓ │ │
│ │ 用户说完(静音检测/手动结束) │ │
│ │ ↓ │ │
│ │ 自动组装 DSL → POST /tasks │ │
│ │ (复用现有任务提交,零改动) │ │
│ │ │ │
│ │ 下行链路: │ │
│ │ 订阅 sundynix.streams.<task_id>(现有 token 流) │ │
│ │ ↓ │ │
│ │ SentenceBuffer(攒到句号/逗号/问号/感叹号/换行) │ │
│ │ ↓ │ │
│ │ volcTTSClient(WS) → 音频帧 → 推回客户端播放 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 现有路由/中间件/NATS 发布/SSE 回流 → 全部不动 │
└──────────────────────────────────────────────────────────────────────┘
┌─────────────┴─────────────┐
▼ ▼
现有后端全套(不改) 火山引擎云端
NATS → Dispatcher ASR WebSocket 端点
Eino 编排 TTS WebSocket 端点
MCP 工具
RAG/报告/记忆
```
### 3.2 关键设计决策
| # | 决策 | 理由 |
|---|---|---|
| D1 | **语音逻辑全在 Gateway 内**,不加新微服务 | 音频只是 I/O 转码,不是业务逻辑;遵循 Monolith First |
| D2 | **Gateway ↔ 火山引擎直连 WebSocket** | 音频帧需毫秒级中继,过 NATS 多一跳反而增延迟 |
| D3 | **客户端 ↔ Gateway 用单条 WebSocket** | 同一连接承载上行音频 + 下行转写 + 下行 TTS 音频,用消息类型区分 |
| D4 | **转写完成后复用 POST /tasks 逻辑** | 语音只是输入方式替换,任务提交/编排/回流全走现有管道 |
| D5 | **TTS 攒句再合成** | 逐 token 喂 TTS 太碎(单字合成不自然);攒到标点再喂,自然度好 |
| D6 | **语音配置走 admin 控制面** | 与模型配置同管理,支持热更新(改音色/切 provider 不重启) |
---
## 4. 数据流详解
### 4.1 上行:用户说话 → 触发任务
```
时间线 →
用户: [====说话中====] [停顿/点击结束]
↓↓↓↓↓↓↓↓↓↓↓↓↓
客户端: 音频帧(PCM 16kHz/16bit) 每100ms一帧(3.2KB)
↓ WebSocket binary
Gateway: 转发 → 火山 ASR WebSocket
← 部分转写结果(partial) ← 火山 ASR
← 最终转写结果(final) ← 火山 ASR
推回客户端显示(实时字幕)
用户停止说话 → 取 final 结果
组装简单 DSL:
{
"version": "1",
"nodes": [{"id":"input","kind":"input","config":{"text":"<转写文字>"}},
{"id":"agent","kind":"agent","config":{"autonomous":true}}],
"edges": [{"source":"input","target":"agent"}]
}
调用现有 h.SubmitTask() 内部逻辑(经 NATS 发布任务)
返回 task_id 给 VoiceSession(用于下行订阅)
```
### 4.2 下行:Agent 回答 → 用户听到
```
时间线 →
Dispatcher: token: "人" → "工" → "智" → "能" → "在" → "医" → "疗" → "领" → "域" → "" → ...
↓ sundynix.streams.<task_id>(现有,不改)
Gateway
VoiceSession: 订阅 token 流,逐 token 累积到 SentenceBuffer
遇到断句符(,。!?\n)→ 截取一句完整文字
喂给火山 TTS WebSocket → 收音频帧(PCM/opus)
WebSocket binary 推给客户端
客户端: AudioContext 播放队列,顺序播放每段音频
同时文字也在屏幕上显示(双通道:看+听)
```
### 4.3 WebSocket 消息协议
客户端 ↔ Gateway 的 WebSocket 用 JSON 控制帧 + Binary 音频帧:
```
// 客户端 → Gateway
// 1. 开始录音
{"type": "asr_start", "session_id": "xxx", "graph": {...}} // 可选携带编排图
// 2. 音频帧(binaryPCM 16kHz 16bit mono
[binary data]
// 3. 停止录音
{"type": "asr_stop"}
// 4. 打断 TTS 播放(用户开始说下一句时)
{"type": "tts_interrupt"}
// ---
// Gateway → 客户端
// 1. ASR 部分结果(实时字幕)
{"type": "asr_partial", "text": "人工智能在医"}
// 2. ASR 最终结果
{"type": "asr_final", "text": "人工智能在医疗领域的应用"}
// 3. 任务已提交
{"type": "task_submitted", "task_id": "task_xxx"}
// 4. 文字流(同步显示)
{"type": "text_chunk", "text": "人工智能在医疗领域,"}
// 5. TTS 音频帧(binary,带前缀字节区分)
[0x01][binary audio data] // 0x01 前缀标识这是 TTS 音频
// 6. 回答完毕
{"type": "done"}
// 7. 错误
{"type": "error", "message": "ASR 连接失败"}
```
---
## 5. 模块设计
### 5.1 新增文件清单
```
sundynix-gateway/
internal/
voice/ ← 新增包
session.go ← VoiceSession:管理一次语音对话的生命周期
asr.go ← 火山 ASR WebSocket 客户端封装
tts.go ← 火山 TTS WebSocket 客户端封装
sentence_buffer.go ← Token 流攒句器
config.go ← 语音配置(appid/token/cluster/voice
handler/
voice.go ← WebSocket 升级 + VoiceSession 入口(新增)
router/
router.go ← 加一条路由(改 1 行)
sundynix-desktop/frontend/
src/
components/
VoiceButton.tsx ← 🎤 按住说话按钮 + 录音逻辑(新增)
VoicePlayer.tsx ← 🔊 TTS 音频播放队列(新增)
lib/
voice.ts ← WebSocket 连接管理 + 音频采集/播放(新增)
sundynix-admin/
src/pages/
DatasourcesPage.tsx ← 加语音配置表单(改几行)
sundynix-shared/
contract/
voice.go ← 语音配置契约 VoiceConfig(新增)
```
### 5.2 Gateway `voice` 包设计
#### session.go — VoiceSession
```go
// VoiceSession 管理一次语音对话的完整生命周期:
// 1. 接收客户端音频帧 → 转发 ASR → 回传转写文字
// 2. 转写完成 → 组装 DSL → 调用现有任务提交
// 3. 订阅 token 流 → 攒句 → 喂 TTS → 回传音频帧
// 4. 支持打断:用户再次说话时中止当前 TTS
type VoiceSession struct {
ws *websocket.Conn // 客户端连接
asr *ASRClient // 火山 ASR
tts *TTSClient // 火山 TTS
buf *SentenceBuffer // 攒句器
bus *nats.Bus // 复用现有 NATS bus
submit func(text, graph) // 复用现有 SubmitTask 逻辑
taskID string // 当前任务 ID
mu sync.Mutex
}
func (s *VoiceSession) Run(ctx context.Context) // 主循环
func (s *VoiceSession) handleUpstream(ctx) // 上行:音频→ASR→转写
func (s *VoiceSession) handleDownstream(ctx) // 下行:token→攒句→TTS→音频
func (s *VoiceSession) interrupt() // 打断 TTS
```
#### asr.go — 火山 ASR 客户端
```go
// ASRClient 封装火山引擎流式语音识别 WebSocket 连接。
// 协议:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
// 上行:音频帧(PCM 16kHz 16bit
// 下行:JSONpartial/final 转写结果)
type ASRClient struct {
conn *websocket.Conn
appid string
token string
cluster string
}
func NewASRClient(cfg VoiceConfig) (*ASRClient, error)
func (c *ASRClient) SendAudio(data []byte) error // 发音频帧
func (c *ASRClient) Recv() (text string, isFinal bool, err error) // 收转写
func (c *ASRClient) Close() error
```
#### tts.go — 火山 TTS 客户端
```go
// TTSClient 封装火山引擎双向流式语音合成 WebSocket 连接。
// 协议:wss://openspeech.bytedance.com/api/v3/tts/bidirection
// 上行:文字(可多次发送,流式喂入)
// 下行:音频帧(PCM/opus,流式返回)
type TTSClient struct {
conn *websocket.Conn
appid string
token string
cluster string
voiceType string
}
func NewTTSClient(cfg VoiceConfig) (*TTSClient, error)
func (c *TTSClient) SendText(text string) error // 喂一句文字
func (c *TTSClient) RecvAudio() (data []byte, done bool, err error) // 收音频
func (c *TTSClient) Close() error
```
#### sentence_buffer.go — 攒句器
```go
// SentenceBuffer 把逐 token 的文字流攒成完整句子。
// 遇到断句符(,。!?;\n)时输出一个句子段,喂给 TTS。
// 设超时兜底:超过 2s 没遇到断句符也强制输出(防长无标点段卡住)。
type SentenceBuffer struct {
buf strings.Builder
out chan string // 攒好的句子
timeout time.Duration // 无标点强制输出超时(默认 2s
}
func NewSentenceBuffer() *SentenceBuffer
func (b *SentenceBuffer) Feed(token string) // 喂一个 token
func (b *SentenceBuffer) Flush() // 强制输出剩余
func (b *SentenceBuffer) Sentences() <-chan string // 读取攒好的句子
```
### 5.3 客户端设计
#### VoiceButton.tsx — 录音按钮
```
两种交互模式:
A. 按住说话(Push-to-Talk):按下录音、松开发送 — 适合短命令
B. 点击切换(Toggle):点一次开始录音、再点一次结束 — 适合长段落
录音参数:
- MediaRecorder / AudioWorklet 采集
- PCM 16kHz 16bit mono(火山 ASR 要求)
- 每 100ms 切一帧发送(~3.2KB/帧)
```
#### VoicePlayer.tsx — 音频播放
```
- Web Audio API (AudioContext) 播放队列
- 收到 TTS 音频帧 → 解码 → 入队 → 顺序播放
- 支持打断:用户再次说话时清空队列、发 tts_interrupt
- 播放状态指示:🔊 动画
```
#### voice.ts — WebSocket 管理
```
- 建立/维护到 gateway /api/v1/voice/stream 的 WebSocket
- 区分 JSON 控制帧和 binary 音频帧
- 自动重连 + 心跳保活
- 暴露 hooksuseVoice() → { startRecording, stopRecording, isListening, transcript, isPlaying }
```
---
## 6. 与现有系统的集成点
### 6.1 改动清单(最小化)
| 文件 | 改动 | 行数 |
|---|---|---|
| `sundynix-gateway/internal/router/router.go` | 加一条 WebSocket 路由 | **+1 行** |
| `sundynix-gateway/internal/handler/voice.go` | 新增 handler(调 VoiceSession | **新文件 ~80 行** |
| `sundynix-gateway/internal/voice/*.go` | 新增包(ASR/TTS/Session/Buffer/Config | **新文件 ~500 行** |
| `sundynix-shared/contract/voice.go` | VoiceConfig 契约 | **新文件 ~30 行** |
| `sundynix-shared/bus/bus.go` | 加 ServeConfig/SubscribeConfig("voice",...) | **复用现有 config 模式,0 改动** |
| `sundynix-admin/.../DatasourcesPage.tsx` | 加语音配置表单 | **+~50 行** |
| `sundynix-desktop/frontend/...` | 新增 3 个文件 | **新文件 ~400 行** |
| **现有后端(Dispatcher/MCP/Eino/NATS** | | **0 改动** |
### 6.2 路由变更
```go
// router.go 加一条:
api.GET("/voice/stream", h.VoiceStream) // WebSocket 升级,须在 Auth 后
```
### 6.3 配置管理(复用现有控制面)
语音配置与模型配置走**完全相同的管道**:
```
Admin 控制台 → POST /admin/voice → Gateway 写 DB → NATS 广播 "voice" 配置
Gateway 自身热更新 VoiceConfig
(ASR/TTS 客户端用新配置重建)
```
契约:
```go
// contract/voice.go
type VoiceConfig struct {
ASRAppID string `json:"asr_appid"`
ASRToken string `json:"asr_token"` // 密文(AES-256-GCM,复用现有 secrets
ASRCluster string `json:"asr_cluster"`
TTSAppID string `json:"tts_appid"`
TTSToken string `json:"tts_token"` // 密文
TTSCluster string `json:"tts_cluster"`
TTSVoiceType string `json:"tts_voice_type"`
TTSEncoding string `json:"tts_encoding"` // pcm / opus / mp3
TTSRate int `json:"tts_rate"` // 24000
Enabled bool `json:"enabled"`
}
```
---
## 7. 实现步骤
### Phase 1ASR(耳朵)— 3 天
```
目标:用户说话 → 屏幕上实时显示转写文字 → 手动提交为任务
```
| # | 任务 | 产出 |
|---|---|---|
| 1.1 | 火山引擎开通流式 ASR 服务,拿到 appid/token/cluster | 配置 |
| 1.2 | `voice/asr.go`:封装火山 ASR WebSocket 客户端 | 代码 |
| 1.3 | `voice/config.go`:读 env 配置 | 代码 |
| 1.4 | `voice/session.go`VoiceSession 上行链路(音频→ASR→转写) | 代码 |
| 1.5 | `handler/voice.go` + `router.go`WebSocket 端点 | 代码 |
| 1.6 | 客户端 `voice.ts` + `VoiceButton.tsx`:录音+WS+显示转写 | 代码 |
| 1.7 | 端到端验证:说话 → 实时转写 → 手动复制到输入框提交 | 验证 |
### Phase 2:语音直接触发任务 — 1 天
```
目标:说完自动提交任务,不用手动操作
```
| # | 任务 | 产出 |
|---|---|---|
| 2.1 | `session.go`:转写完成 → 组装默认 DSL → 调 SubmitTask | 代码 |
| 2.2 | 客户端:说完 → 自动提交 → 切到任务运行视图 | 代码 |
| 2.3 | 支持携带当前画布编排图(用户在 Studio 页说话时用画布图跑) | 代码 |
### Phase 3TTS(嘴巴)— 3-4 天
```
目标:Agent 回答时语音朗读
```
| # | 任务 | 产出 |
|---|---|---|
| 3.1 | 火山引擎开通双向流式 TTS,选音色 | 配置 |
| 3.2 | `voice/tts.go`:封装火山 TTS WebSocket 客户端 | 代码 |
| 3.3 | `voice/sentence_buffer.go`:token 流攒句器 | 代码 + 单测 |
| 3.4 | `session.go`:下行链路(订阅 token 流→攒句→TTS→音频推送) | 代码 |
| 3.5 | 客户端 `VoicePlayer.tsx`AudioContext 播放队列 | 代码 |
| 3.6 | 端到端验证:说话 → 任务执行 → Agent 边想边说 | 验证 |
### Phase 4:打断 + 连续对话 — 2 天
```
目标:自然对话体验
```
| # | 任务 | 产出 |
|---|---|---|
| 4.1 | 打断:用户再次说话时中止 TTS + 清空播放队列 | 代码 |
| 4.2 | 连续对话:上一轮完毕后自动重新激活麦克风 | 代码 |
| 4.3 | 会话历史串联:同一 session_id 下多轮对话共享上下文 | 代码 |
| 4.4 | 静音检测(VAD):客户端 3s 无声自动结束录音 | 代码 |
### Phase 5:控制面 + 上线 — 2 天
```
目标:运维可管理,生产可用
```
| # | 任务 | 产出 |
|---|---|---|
| 5.1 | Admin 控制台加语音配置页(ASR/TTS appid/token/音色选择) | 代码 |
| 5.2 | `contract/voice.go` + token 密文存储(复用 secrets | 代码 |
| 5.3 | 语音配置热更新(NATS 广播,复用 config 模式) | 代码 |
| 5.4 | 健康检查:`/admin/status` 加 ASR/TTS 连通性探测 | 代码 |
| 5.5 | 语音用量计量(ASR 秒数 + TTS 字数)→ 现有计费管道 | 代码 |
| 5.6 | `.env.example` 加语音配置项说明 | 文档 |
---
## 8. 性能与延迟分析
### 8.1 端到端延迟拆解
```
用户说完最后一个字 → 听到 Agent 第一个字的时间:
ASR 尾部延迟 ~300ms (火山 ASR final 结果延迟)
+ 任务提交 → NATS ~10ms (现有管道)
+ Dispatcher 消费 ~5ms (现有管道)
+ LLM TTFT ~500ms (首 token 延迟,取决于模型)
+ 攒句(到第一个标点) ~200ms (模型每秒约 30-50 token,逗号很快出现)
+ TTS 首段合成 ~200ms (火山 TTS 流式首包延迟)
─────────────────────────────────
总计 ~1.2s ← 可接受(人类对话轮转间隔约 0.5-2s)
```
### 8.2 后续句子的延迟
首句之后,**TTS 与 LLM 推理流水线重叠**——LLM 在生成下一句时,上一句的 TTS 还在播放。用户感知是连续朗读,无等待。
### 8.3 音频带宽
```
上行(ASR: PCM 16kHz 16bit mono = 32KB/s ≈ 256kbps → 完全可接受
下行(TTS:
- PCM 24kHz 16bit: 48KB/s
- opus 编码后: ~6-12KB/s ≈ 48-96kbps → 推荐用 opus 省带宽
```
---
## 9. 安全考量
| 维度 | 措施 |
|---|---|
| **ASR/TTS Token** | 复用现有 `secrets.Encrypt/Decrypt` (AES-256-GCM)DB 存密文,NATS 过密文 |
| **WebSocket 鉴权** | 升级前经过 `middleware.Auth()`,未登录不可连 |
| **音频不落盘** | 语音帧只在内存中转,不存储不持久化 |
| **TTS 内容脱敏** | 复用现有 `harness/output.go` 的流式脱敏——脱敏后的文字再喂 TTS |
| **限流** | 语音连接数按用户限制(默认同时 1 个语音会话) |
---
## 10. 可观测性
| 指标 | 来源 |
|---|---|
| ASR 识别延迟 | Gateway OTel span `voice.asr` |
| TTS 合成延迟 | Gateway OTel span `voice.tts` |
| 语音会话数 | Prometheus gauge `sundynix_voice_sessions_active` |
| ASR 用量(秒) | 计入现有用量管道 |
| TTS 用量(字) | 计入现有用量管道 |
| 错误率 | span error + Prometheus counter |
---
## 11. 未来扩展(本期不做)
| 方向 | 说明 |
|---|---|
| **声纹识别** | 用声纹替代/辅助登录鉴权 |
| **多语种实时翻译** | ASR → 翻译 → TTS,三段流水线 |
| **自定义唤醒词** | "Hey Sundynix" 免点击启动 |
| **音色克隆** | 用户上传自己的声音,Agent 用用户喜欢的声音回答 |
| **Provider failover** | ASR/TTS 也做主备链(火山→讯飞),复用 LLM failover 模式 |
---
## 12. 总工期与资源
| 阶段 | 内容 | 工期 |
|---|---|---|
| Phase 1 | ASR(耳朵) | 3 天 |
| Phase 2 | 语音触发任务 | 1 天 |
| Phase 3 | TTS(嘴巴) | 3-4 天 |
| Phase 4 | 打断 + 连续对话 | 2 天 |
| Phase 5 | 控制面 + 上线 | 2 天 |
| **总计** | | **~2 周** |
**改动影响**
```diff
+ 新增文件: ~10 个(Go 5 个 + TS 3 个 + 契约 1 个 + handler 1 个)
+ 新增代码: ~1,500 行(Go ~600 + TS ~400 + 测试 ~300 + 配置/文档 ~200
~ 修改文件: 3 个(router.go +1行, DatasourcesPage.tsx +50行, .env.example +8行)
不动文件: Dispatcher / MCP-Go / MCP-Py / Eino / NATS bus / shared 核心 = 0 改动
```
---
*本文档描述语音交互功能的设计方案;实现时以本文档为准,如有重大变更需更新本文档。*