Files
sundynix-agentix/VOICE_DESIGN.md
T

24 KiB
Raw Blame History

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
双向流式语音合成 (V3) 🔊 文字 → Agent 说话 WebSocket 双向流 双向流式 TTS WebSocket V3

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

// 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 客户端

// 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 客户端

// 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 — 攒句器

// 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 路由变更

// router.go 加一条:
api.GET("/voice/stream", h.VoiceStream)  // WebSocket 升级,须在 Auth 后

6.3 配置管理(复用现有控制面)

语音配置与模型配置走完全相同的管道

Admin 控制台 → POST /admin/voice → Gateway 写 DB → NATS 广播 "voice" 配置
                                                    ↓
                                              Gateway 自身热更新 VoiceConfig
                                              (ASR/TTS 客户端用新配置重建)

契约:

// 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.goVoiceSession 上行链路(音频→ASR→转写) 代码
1.5 handler/voice.go + router.goWebSocket 端点 代码
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.gotoken 流攒句器 代码 + 单测
3.4 session.go:下行链路(订阅 token 流→攒句→TTS→音频推送) 代码
3.5 客户端 VoicePlayer.tsxAudioContext 播放队列 代码
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 周

改动影响

+ 新增文件: ~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 改动

本文档描述语音交互功能的设计方案;实现时以本文档为准,如有重大变更需更新本文档。