diff --git a/.dockerignore b/.dockerignore
index d632da5ea..aef5d768a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,3 +8,6 @@ config/
*.md
LICENSE
assets/
+# picoclaw-voice binary
+cmd/picoclaw-voice/picoclaw-voice
+cmd/picoclaw-voice/.env
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 000000000..cc2306b50
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,6 @@
+- 必须用简体中文回答问题,这是铁律!
+- 需求不明确要先确认再行动
+- 如果我的要求不合理,你要大胆的提出来,并给出合理的建议
+- 所有修改以代码质量优先,默认不考虑与旧逻辑兼容
+- 提交代码时同步更新文档
+- 注释和代码提交使用中文,日志打印使用英文
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 61fe494ca..7ffe3becd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,9 @@ build/
*.out
/picoclaw
/picoclaw-test
+# picoclaw-voice binary (cmd/picoclaw-voice/ module)
+/picoclaw-voice
+cmd/picoclaw-voice/picoclaw-voice
cmd/**/workspace
# Picoclaw specific
@@ -25,6 +28,7 @@ build/
# Secrets & Config (keep templates, ignore actual secrets)
.env
config/config.json
+config/config.yaml
# Test
coverage.txt
diff --git a/cmd/picoclaw-voice/.env.example b/cmd/picoclaw-voice/.env.example
new file mode 100644
index 000000000..2cc4e4867
--- /dev/null
+++ b/cmd/picoclaw-voice/.env.example
@@ -0,0 +1,58 @@
+# picoclaw-voice 配置样例
+# 启动方式(推荐):
+# cp .env.example .env # 编辑 .env 填入实际值
+# ./picoclaw-voice
+
+# ─── 监听 ──────────────────────────────────────────────────────────────────────
+PICOCLAW_VOICE_LISTEN=:8765
+
+# ─── picoclaw 配置路径(LLM / 记忆 / MCP 工具) ───────────────────────────────
+# 默认读取 ~/.picoclaw/config.json,与 picoclaw 进程共享同一份配置。
+# PICOCLAW_CONFIG=/path/to/config.json
+# PICOCLAW_HOME=/path/to/.picoclaw
+
+# ─── 多轮记忆:固定 owner_id ──────────────────────────────────────────────────
+# 设置后所有设备共享同一份对话记忆;留空则每台设备独立记忆。
+# PICOCLAW_VOICE_OWNER_ID=alice
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 方案 A:doubao(火山引擎)供应商(默认)
+# ══════════════════════════════════════════════════════════════════════════════
+# ASR + TTS 使用同一套火山引擎账号时,只需填共享凭证:
+PICOCLAW_VOICE_APPID=your_volcengine_appid
+PICOCLAW_VOICE_TOKEN=your_volcengine_token
+
+# ASR(可选覆盖)
+PICOCLAW_VOICE_ASR_PROVIDER=doubao
+# PICOCLAW_VOICE_ASR_APPID= # 覆盖共享 AppID
+# PICOCLAW_VOICE_ASR_TOKEN= # 覆盖共享 Token
+PICOCLAW_VOICE_ASR_CLUSTER=bigmodel_transcribe
+PICOCLAW_VOICE_ASR_RESOURCE_ID=volc.bigasr.sauc.duration
+
+# TTS(可选覆盖)
+PICOCLAW_VOICE_TTS_PROVIDER=doubao
+# PICOCLAW_VOICE_TTS_APPID= # 覆盖共享 AppID
+# PICOCLAW_VOICE_TTS_TOKEN= # 覆盖共享 Token
+PICOCLAW_VOICE_TTS_CLUSTER=volcano_tts
+PICOCLAW_VOICE_TTS_VOICE=zh_female_wanwanxiaohe_moon_bigtts
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 方案 B:本地免费方案(FunASR + Fish Speech)
+# 需要在本机先启动 FunASR(端口 10095)和 Fish Speech(端口 8080)服务
+# 参见:docker/docker-compose.asr-tts.yml
+# ══════════════════════════════════════════════════════════════════════════════
+# ASR:FunASR 本地 WebSocket(支持中文,2pass 模式实时转写+高精度)
+# PICOCLAW_VOICE_ASR_PROVIDER=funasr
+# PICOCLAW_VOICE_ASR_WS_URL=wss://127.0.0.1:10095 # 内置自签名证书,跳过校验
+# PICOCLAW_VOICE_ASR_MODE=2pass # 可选:2pass(默认)| online | offline
+
+# TTS:Fish Speech 本地 HTTP(PCM 输出,中文效果好,推荐 GPU)
+# PICOCLAW_VOICE_TTS_PROVIDER=fishspeech
+# PICOCLAW_VOICE_TTS_API_URL=http://127.0.0.1:8080
+# PICOCLAW_VOICE_TTS_REFERENCE_ID= # 留空用默认音色
+# PICOCLAW_VOICE_TTS_SAMPLE_RATE=44100
+
+# ══════════════════════════════════════════════════════════════════════════════
+# 方案 C:混搭(例如 doubao ASR + Fish Speech TTS)
+# ══════════════════════════════════════════════════════════════════════════════
+# 只需分别设置 ASR/TTS 对应的 PROVIDER 及其凭证字段,两套配置互不干扰。
diff --git a/cmd/picoclaw-voice/Dockerfile b/cmd/picoclaw-voice/Dockerfile
new file mode 100644
index 000000000..208ca3919
--- /dev/null
+++ b/cmd/picoclaw-voice/Dockerfile
@@ -0,0 +1,26 @@
+# ─── Build stage ─────────────────────────────────────────────────────────────
+# Build context: picoclaw repo root
+# go.mod 中有 replace github.com/sipeed/picoclaw => ../../,需要 repo 根目录可见
+FROM golang:1.25-alpine AS builder
+
+# 国内 Go 模块代理
+ENV GOPROXY=https://goproxy.cn,direct
+ENV GONOSUMCHECK=*
+
+# 复制整个 repo,保证 replace 指令的相对路径 ../../ 在容器内成立
+WORKDIR /build
+COPY . .
+
+WORKDIR /build/cmd/picoclaw-voice
+RUN go mod download
+# 纯 Go 静态构建,无 CGO 依赖
+RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /app/picoclaw-voice .
+
+# ─── Runtime stage ────────────────────────────────────────────────────────────
+FROM scratch
+
+COPY --from=golang:1.25-alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
+COPY --from=builder /app/picoclaw-voice /picoclaw-voice
+
+EXPOSE 8765
+CMD ["/picoclaw-voice"]
diff --git a/cmd/picoclaw-voice/README.md b/cmd/picoclaw-voice/README.md
new file mode 100644
index 000000000..b6ca71f5d
--- /dev/null
+++ b/cmd/picoclaw-voice/README.md
@@ -0,0 +1,208 @@
+# picoclaw-voice
+
+xiaozhi WebSocket 语音网关,将 xiaozhi-esp32 协议设备(ESP32、桌面客户端等)接入 PicoClaw AI 引擎,提供完整的 ASR → LLM → TTS 语音对话流水线。
+
+## 功能特性
+
+- **WebSocket 服务端**:实现 [xiaozhi-esp32 协议](../../docs/channels/xiaozhi/README.zh.md) v3,路径 `/xiaozhi/v1/`
+- **音频格式协商**:服务端在 hello 握手阶段通过 `asr_params` / `tts_params` 下发上下行格式(PCM 或 Opus),客户端自动适配
+- **三段并发流水线**:实时 ASR(接收音频帧即转写)→ LLM 推理(流式断句)→ TTS 合成推流,端到端首字延迟最优
+- **多 provider 支持**:ASR 支持豆包(云端)和 FunASR(本地),TTS 支持豆包(云端)和 Fish Speech(本地),可自由混搭
+- **设备管理**:`device_id` 注册表,同一设备重连时自动驱逐旧会话
+- **多轮记忆**:通过 `PICOCLAW_VOICE_OWNER_ID` 控制跨设备/跨频道共享同一对话记忆
+- **LLM thinking 通知**:支持推理模型 `` 块,向客户端发送 `llm.thinking_start/end` 事件
+
+## 快速开始
+
+### 直接运行(推荐)
+
+```bash
+# 1. 构建
+cd cmd/picoclaw-voice
+go build -o picoclaw-voice .
+
+# 2. 配置
+cp .env.example .env
+$EDITOR .env # 至少填写 LLM 配置和 ASR/TTS 凭证
+
+# 3. 启动
+./picoclaw-voice
+```
+
+### Docker
+
+```bash
+# 从 repo 根目录构建(Dockerfile context 需要整个 repo)
+docker build -f cmd/picoclaw-voice/Dockerfile -t picoclaw-voice .
+
+docker run -d --name picoclaw-voice \
+ --env-file cmd/picoclaw-voice/.env \
+ -v ~/.picoclaw:/root/.picoclaw \
+ -p 8765:8765 \
+ picoclaw-voice
+```
+
+### 连接到 picoclaw
+
+picoclaw-voice 读取 `~/.picoclaw/config.json`(与 picoclaw 主进程共享同一份配置)以获取 LLM provider、记忆后端和 MCP 工具配置。
+
+确保 picoclaw 已完成 [初始化配置](https://github.com/sipeed/picoclaw#configuration) 后再启动 picoclaw-voice。
+
+## 环境变量
+
+> 加载优先级(高→低):Shell 进程环境 → `.env` 文件 → 代码默认值
+
+### 通用
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `PICOCLAW_VOICE_LISTEN` | `:8765` | WebSocket 监听地址 |
+| `PICOCLAW_VOICE_OWNER_ID` | *(空)* | 固定 owner_id;空时每台设备独立记忆 |
+| `PICOCLAW_CONFIG` | `~/.picoclaw/config.json` | picoclaw 配置文件路径 |
+| `PICOCLAW_HOME` | `~/.picoclaw` | picoclaw home 目录(次优先) |
+
+### ASR 配置
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `PICOCLAW_VOICE_ASR_PROVIDER` | `doubao` | ASR 供应商:`doubao` \| `funasr` |
+| `PICOCLAW_VOICE_APPID` | | 火山引擎 AppID(`doubao` provider 兜底) |
+| `PICOCLAW_VOICE_TOKEN` | | 火山引擎 Token(`doubao` provider 兜底) |
+| `PICOCLAW_VOICE_ASR_APPID` | | 单独覆盖 ASR AppID |
+| `PICOCLAW_VOICE_ASR_TOKEN` | | 单独覆盖 ASR Token |
+| `PICOCLAW_VOICE_ASR_CLUSTER` | `bigmodel_transcribe` | doubao ASR 集群 |
+| `PICOCLAW_VOICE_ASR_RESOURCE_ID` | `volc.bigasr.sauc.duration` | doubao ASR 资源 ID(见下方模型列表) |
+| `PICOCLAW_VOICE_ASR_WS_URL` | `wss://127.0.0.1:10095` | funasr WebSocket 地址(内置自签名证书,自动跳过校验) |
+| `PICOCLAW_VOICE_ASR_MODE` | `2pass` | funasr 识别模式:`2pass` \| `online` \| `offline` |
+
+### TTS 配置
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `PICOCLAW_VOICE_TTS_PROVIDER` | `doubao` | TTS 供应商:`doubao` \| `fishspeech` |
+| `PICOCLAW_VOICE_TTS_APPID` | | 单独覆盖 TTS AppID(doubao) |
+| `PICOCLAW_VOICE_TTS_TOKEN` | | 单独覆盖 TTS Token(doubao) |
+| `PICOCLAW_VOICE_TTS_CLUSTER` | `volcano_tts` | doubao TTS 集群 |
+| `PICOCLAW_VOICE_TTS_VOICE` | | doubao 音色 ID,见[火山引擎音色列表](https://www.volcengine.com/docs/6561/97465) |
+| `PICOCLAW_VOICE_TTS_API_URL` | `http://127.0.0.1:8080` | fishspeech HTTP 服务地址 |
+| `PICOCLAW_VOICE_TTS_API_KEY` | | fishspeech Bearer Token(不需要鉴权时留空) |
+| `PICOCLAW_VOICE_TTS_REFERENCE_ID` | | fishspeech 参考音色 ID(留空用服务默认) |
+| `PICOCLAW_VOICE_TTS_SAMPLE_RATE` | `0` | fishspeech 输出采样率(0 = provider 默认,通常为 44100 Hz) |
+
+## ASR Provider 说明
+
+### doubao(豆包,默认)
+
+调用[火山引擎大模型语音识别](https://console.volcengine.com/speech/service/10),流式实时转写。
+
+**凭证填写方式(两选一):**
+- **API Key 模式**(推荐):只填 `PICOCLAW_VOICE_TOKEN`(UUID 格式),`APPID` 留空
+- **App Key 模式**:填数字格式 `PICOCLAW_VOICE_APPID` + `PICOCLAW_VOICE_TOKEN`
+
+**模型选择(`PICOCLAW_VOICE_ASR_RESOURCE_ID`):**
+- `volc.bigasr.sauc.duration` — 豆包流式 ASR 1.0(默认,稳定)
+- `volc.seedasr.sauc.duration` — 豆包流式 ASR 2.0 Seed(更高精度)
+
+### funasr(本地免费)
+
+连接本机运行的 [FunASR](https://github.com/modelscope/FunASR) 推理服务,免费且支持中文。
+
+**快速启动 FunASR 容器:**
+
+```bash
+# 从 repo 根目录执行
+docker compose -f docker/docker-compose.asr-tts.yml up -d funasr
+```
+
+模型首次启动时自动从 ModelScope 下载(约 1 GB),后续启动使用缓存(`~/models/funasr/`)。
+
+## TTS Provider 说明
+
+### doubao(豆包,默认)
+
+调用[火山引擎语音合成](https://console.volcengine.com/speech/service/8),输出 Opus 音频流。凭证配置同 doubao ASR。
+
+### fishspeech(本地免费,推荐 GPU)
+
+连接本机运行的 [Fish Speech](https://github.com/fishaudio/fish-speech) v1.5.x 服务,输出 PCM 音频流(44100 Hz),效果自然、中文优秀。
+
+**快速启动 Fish Speech 容器(需 NVIDIA GPU):**
+
+```bash
+# 安装 NVIDIA Container Toolkit(首次)
+# 参见:https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
+
+# 从 repo 根目录执行
+docker compose -f docker/docker-compose.asr-tts.yml up -d fishspeech
+```
+
+模型已内置于 `fishaudio/fish-speech:v1.5.1` 镜像,无需单独挂载。
+
+## 本地免费方案(FunASR + Fish Speech)
+
+一键启动完整本地推理栈:
+
+```bash
+docker compose -f docker/docker-compose.asr-tts.yml up -d
+```
+
+然后在 `.env` 中切换 provider:
+
+```dotenv
+PICOCLAW_VOICE_ASR_PROVIDER=funasr
+PICOCLAW_VOICE_ASR_WS_URL=wss://127.0.0.1:10095
+
+PICOCLAW_VOICE_TTS_PROVIDER=fishspeech
+PICOCLAW_VOICE_TTS_API_URL=http://127.0.0.1:8080
+```
+
+也可以混搭,例如云端 ASR + 本地 TTS,只需分别设置对应的 `PROVIDER` 变量即可。
+
+## 协议说明
+
+picoclaw-voice 实现 xiaozhi-esp32 协议 v3,并在此基础上做了少量扩展。完整协议文档见 [docs/channels/xiaozhi/README.zh.md](../../docs/channels/xiaozhi/README.zh.md)。
+
+**音频格式协商流程:**
+
+1. 客户端发送 `hello`,携带上行 `audio_params`(建议 PCM 16kHz mono)
+2. 服务端回 `hello`,携带:
+ - `asr_params`:服务端期望的**上行**音频格式(客户端按此格式发送语音帧)
+ - `tts_params`:服务端实际的**下行** TTS 格式(doubao 输出 Opus,fishspeech 输出 PCM)
+3. 后续二进制帧格式以协商结果为准
+
+**picoclaw 扩展字段:**
+
+| 方向 | 字段 | 说明 |
+|------|------|------|
+| 客户端→服务端 | `listen.memory_id` | 指定 LLM 多轮记忆 key,相同 key 共享对话上下文(跨设备/跨会话) |
+| 服务端→客户端 | `hello.session_id` | 连接级 UUID,用于日志关联 |
+| 服务端→客户端 | `llm`(新消息类型) | LLM 推理通知:`{"type":"llm","text":"..."}` 断句文本;`thinking_start` / `thinking_end` 思考链事件 |
+
+## Demo
+
+Python 演示客户端,完整演示握手→录音→流式 ASR→LLM→TTS 播放全流程。
+
+**系统依赖(仅 doubao TTS/ASR 的 Opus 模式需要):**
+
+```bash
+# Debian/Ubuntu
+sudo apt install portaudio19-dev libopus-dev
+
+# macOS
+brew install portaudio opus
+```
+
+**运行:**
+
+```bash
+cd cmd/picoclaw-voice/demo
+pip install -r requirements.txt
+
+# 麦克风模式:按空格开始/停止录音
+python client.py --url ws://localhost:8765/xiaozhi/v1/
+
+# 文件模式:自动发送音频文件后退出
+python client.py --audio-file /tmp/input.wav
+```
+
+详见 [demo/README.md](demo/README.md)。
diff --git a/cmd/picoclaw-voice/demo/README.md b/cmd/picoclaw-voice/demo/README.md
new file mode 100644
index 000000000..175f8c0e4
--- /dev/null
+++ b/cmd/picoclaw-voice/demo/README.md
@@ -0,0 +1,62 @@
+# picoclaw-voice demo
+
+演示如何通过 xiaozhi 协议连接 picoclaw-voice,完整体验 ASR → LLM → TTS 流水线。
+
+支持两种 TTS 下行音频格式(由服务端协商决定,客户端自动适配):
+- **opus**:doubao TTS 输出,需要 `opuslib` 解码
+- **pcm**:Fish Speech TTS 输出,直接播放 s16le PCM
+
+## 系统依赖
+
+`opuslib` 仅在 doubao TTS(Opus 格式)时需要:
+
+```bash
+# Debian/Ubuntu
+sudo apt install portaudio19-dev libopus-dev
+
+# macOS
+brew install portaudio opus
+```
+
+使用 Fish Speech TTS(PCM 格式)时无需 opus 系统库。
+
+## 安装 Python 依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+`opuslib` 为可选依赖,仅 doubao Opus 格式时使用。
+
+## 运行
+
+```bash
+# 麦克风模式:按空格开始录音,再按空格停止
+python client.py
+
+# 指定服务地址
+python client.py --url ws://192.168.1.100:8765/xiaozhi/v1/
+
+# 文件模式:自动发送音频文件,TTS 播放完毕后退出
+python client.py --audio-file /tmp/input.wav
+```
+
+## 输出示例
+
+```
+[连接] ws://localhost:8765/xiaozhi/v1/
+[握手] session_id=a1b2c3d4-...
+ ↑ ASR上行: pcm 16000Hz 1ch
+ ↓ TTS下行: opus 24000Hz 1ch
+[就绪] 按空格开始/停止录音,Ctrl+C 退出
+
+────────────────────────────────────────
+[●] 音频数据发送中... (再按空格停止)
+[○] 音频数据发送已停止...
+ [流式识别结果] 你好
+ [你] 你好,今天天气怎么样?
+ [LLM] 您好!今天北京的天气是晴天,气温约 25 度...
+ [合成中]
+ [合成完毕]
+```
+
diff --git a/cmd/picoclaw-voice/demo/client.py b/cmd/picoclaw-voice/demo/client.py
new file mode 100644
index 000000000..cc4481cfb
--- /dev/null
+++ b/cmd/picoclaw-voice/demo/client.py
@@ -0,0 +1,439 @@
+#!/usr/bin/env python3
+"""
+picoclaw-voice 演示客户端
+
+完整演示 ASR → LLM → TTS 流水线及 picoclaw 协议(音频格式协商、llm thinking 事件)。
+
+支持两种 TTS 下行格式(由服务端协商):
+ - opus:接收 Opus 包,用 opuslib 解码后播放(doubao TTS)
+ - pcm:接收原始 PCM s16le 数据,直接播放(Fish Speech TTS)
+
+协议流程:
+ 1. 客户端发 hello
+ 2. 服务端回 hello,携带 asr_params / tts_params(协商音频格式)
+ 3. 按空格 → 发 listen.start,流式发送麦克风音频帧
+ 4. 再按空格 → 发 listen.end,停止发送
+ 5. 服务端推送事件:stt / llm / tts
+ 6. 服务端在 tts 期间推送二进制 Opus 帧
+
+用法:
+ python client.py --url ws://HOST:18765/xiaozhi/v1/
+ python client.py --audio-file /tmp/input.wav # 文件模式,自动发送后退出
+
+依赖:pip install -r requirements.txt
+"""
+
+import argparse
+import json
+import os
+import platform
+import queue
+import signal
+import sys
+import threading
+import time
+import uuid
+
+try:
+ import miniaudio
+ import pyaudio
+ import websocket
+except ImportError as e:
+ print(f"缺少依赖:{e}")
+ print("请先执行:pip install -r requirements.txt")
+ sys.exit(1)
+
+try:
+ import opuslib
+ _HAS_OPUSLIB = True
+except ImportError:
+ _HAS_OPUSLIB = False
+
+SAMPLE_RATE = 16000
+CHANNELS = 1
+FRAME_DURATION_MS = 60
+FRAME_SAMPLES = SAMPLE_RATE * FRAME_DURATION_MS // 1000 # 960 samples = 60ms
+PYAUDIO_FORMAT = pyaudio.paInt16
+
+
+class PicoclawVoiceClient:
+
+ def __init__(self, url: str):
+ self.url = url
+ self.device_id = "demo:" + uuid.uuid4().hex[:8]
+ self.ws = None
+
+ # 握手后由服务端 hello 的 audio_params / tts_params 更新
+ self.audio_fmt = "pcm" # 上行 ASR 格式
+ self.tts_sample_rate = SAMPLE_RATE # 下行 TTS 采样率
+ self.tts_channels = CHANNELS # 下行 TTS 声道数
+ self.tts_format = "opus" # 下行 TTS 编码格式
+ self.handshake_done = threading.Event()
+ self._stop = threading.Event() # 通知播放线程退出
+ self._is_listening = False # 是否正在发送音频帧给服务端
+ self._pushing = False # 空格是否正被按住
+ self._file_mode = False # 文件输入模式
+
+ self.pa = pyaudio.PyAudio()
+ self._audio_device = self._find_pulse_device()
+ self.audio_out_q: queue.Queue = queue.Queue()
+ self.dec = None # 据 tts_format 延迟初始化
+ self.enc = None # 据 audio_fmt 延迟初始化
+
+ def _log(self, msg: str = ""):
+ # raw tty 模式(Linux)下 \n 不回行首,必须用 \r\n
+ end = "\n" if platform.system() == "Windows" else "\r\n"
+ sys.stdout.write(msg + end)
+ sys.stdout.flush()
+
+ def _find_pulse_device(self):
+ """返回 PulseAudio 设备索引,找不到时返回 None(让 PyAudio 用系统默认)。"""
+ for i in range(self.pa.get_device_count()):
+ info = self.pa.get_device_info_by_index(i)
+ if "pulse" in info["name"].lower():
+ return i
+ return None
+
+ # ── WebSocket 回调 ────────────────────────────────────────────────────────
+
+ def on_open(self, ws):
+ self._log(f"[连接] {self.url}")
+ ws.send(json.dumps({
+ "type": "hello",
+ "version": 3,
+ "transport": "websocket",
+ "device_id": self.device_id,
+ "audio_params": {
+ "format": "pcm",
+ "sample_rate": SAMPLE_RATE,
+ "channels": CHANNELS,
+ "frame_duration": FRAME_DURATION_MS,
+ },
+ }))
+
+ def on_message(self, ws, message):
+ if isinstance(message, bytes):
+ # 下行 Opus 音频帧(tts.sentence_start/end 之间)
+ self.audio_out_q.put(message)
+ return
+ try:
+ msg = json.loads(message)
+ except json.JSONDecodeError:
+ return
+
+ mtype = msg.get("type", "")
+
+ if mtype == "hello":
+ # 上行:ASR 期望格式(客户端发送音频给服务端)
+ asr_params = msg.get("asr_params", {})
+ self.audio_fmt = asr_params.get("format", "pcm")
+ # 下行:TTS 输出格式(服务端发送 Opus 音频给客户端)
+ tts_params = msg.get("tts_params", {})
+ self.tts_sample_rate = tts_params.get("sample_rate", SAMPLE_RATE)
+ self.tts_channels = tts_params.get("channels", CHANNELS)
+ self.tts_format = tts_params.get("format", "opus")
+ if self.tts_format == "opus":
+ if not _HAS_OPUSLIB:
+ raise RuntimeError("TTS 格式为 opus,但 opuslib 未安装:pip install opuslib")
+ self.dec = opuslib.Decoder(self.tts_sample_rate, self.tts_channels)
+ if self.audio_fmt == "opus":
+ if not _HAS_OPUSLIB:
+ raise RuntimeError("ASR 上行格式为 opus,但 opuslib 未安装:pip install opuslib")
+ self.enc = opuslib.Encoder(SAMPLE_RATE, CHANNELS, opuslib.APPLICATION_VOIP)
+ self._log(f"[握手] session_id={msg.get('session_id', '')}")
+ self._log(f" ↑ ASR上行: {self.audio_fmt} {asr_params.get('sample_rate')}Hz {asr_params.get('channels')}ch")
+ self._log(f" ↓ TTS下行: {self.tts_format} {self.tts_sample_rate}Hz {self.tts_channels}ch")
+ self.handshake_done.set()
+ if self._file_mode:
+ self._log("[就绪] 文件模式,正在自动发送音频...")
+ else:
+ self._log("[就绪] 按空格开始/停止录音,Ctrl+C 退出")
+
+ elif mtype == "stt":
+ state = msg.get("state", "")
+ text = msg.get("text", "")
+ if state == "recognizing" and text:
+ self._log(f" [流式识别结果] {text}")
+ elif state == "stop":
+ if text:
+ self._log(f" [你] {text}")
+ else:
+ self._log(" [你] (静音/未识别)")
+
+ elif mtype == "llm":
+ state = msg.get("state", "")
+ text = msg.get("text", "")
+ if state == "thinking_start":
+ self._log(" ⏳ 思考中...")
+ elif state == "thinking_end":
+ self._log(f" ✓ 思考完成({msg.get('duration_ms', 0)}ms)")
+ elif text:
+ self._log(f" [LLM] {text}")
+
+ elif mtype == "tts":
+ state = msg.get("state", "")
+ if state == "start":
+ self._log(" [合成中]")
+ elif state == "stop":
+ self._log(" [合成完毕]")
+ if self._file_mode:
+ threading.Thread(target=lambda: (time.sleep(0.5), self.ws.close()), daemon=True).start()
+ else:
+ self._log("")
+ self._log("[就绪] 按空格开始/停止录音,Ctrl+C 退出")
+ elif state == "abort":
+ self._log(" [合成中断]")
+ if not self._file_mode:
+ self._log("")
+ self._log("[就绪] 按空格开始/停止录音,Ctrl+C 退出")
+
+ def on_error(self, ws, error):
+ self._log(f"[错误] {error}")
+
+ def on_close(self, ws, code, msg):
+ self._log(f"[断开] {code} {msg}")
+ self.handshake_done.set()
+
+ # ── PTT 按键控制 ───────────────────────────────────────────────────────────
+
+ def _key_thread(self):
+ """跨平台按键读取:空格切换 PTT,Ctrl+C 退出。"""
+ self.handshake_done.wait()
+ if platform.system() == "Windows":
+ import msvcrt
+ while not self._stop.is_set():
+ if msvcrt.kbhit():
+ ch = msvcrt.getwch()
+ if ch == ' ':
+ self._push_start() if not self._pushing else self._push_end()
+ elif ch == '\x03': # Ctrl+C
+ self._stop.set()
+ if self.ws:
+ self.ws.close()
+ os.kill(os.getpid(), signal.SIGINT)
+ break
+ else:
+ threading.Event().wait(0.05)
+ else:
+ import select, termios, tty
+ fd = sys.stdin.fileno()
+ old = termios.tcgetattr(fd)
+ try:
+ tty.setraw(fd)
+ while not self._stop.is_set():
+ r, _, _ = select.select([fd], [], [], 0.1)
+ if not r:
+ continue
+ ch = os.read(fd, 1)
+ if ch == b' ':
+ self._push_start() if not self._pushing else self._push_end()
+ elif ch in (b'\x03', b'\x1c'):
+ self._stop.set()
+ if self.ws:
+ self.ws.close()
+ os.kill(os.getpid(), signal.SIGINT)
+ break
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
+
+ def _push_start(self):
+ """空格按下:发 listen.start,开始流式发送音频。"""
+ if self._pushing or self._stop.is_set():
+ return
+ self._pushing = True
+ sid = str(uuid.uuid4())
+ self._is_listening = True
+ try:
+ self.ws.send(json.dumps({"type": "listen", "state": "start", "session_id": sid}))
+ self._log("")
+ self._log("─" * 40)
+ self._log("[●] 音频数据发送中... (再按空格停止)")
+ except Exception:
+ self._is_listening = False
+ self._pushing = False
+
+ def _push_end(self):
+ """空格松开:停止发送音频,发 listen.end。"""
+ if not self._pushing:
+ return
+ self._pushing = False
+ self._is_listening = False
+ try:
+ self.ws.send(json.dumps({"type": "listen", "state": "end"}))
+ self._log("[○] 音频数据发送已停止...")
+ except Exception:
+ pass
+
+ # ── 下行音频播放(兼容 opus / pcm)────────────────────────────────────────
+
+ def _playback_thread(self):
+ self.handshake_done.wait()
+ max_frame_size = self.tts_sample_rate * 120 // 1000
+ bytes_per_frame = self.tts_channels * 2 # s16le
+
+ _buf = bytearray()
+ _lock = threading.Lock()
+
+ def _feed_loop():
+ while not self._stop.is_set():
+ try:
+ frame = self.audio_out_q.get(timeout=0.1)
+ if self.tts_format == "opus":
+ pcm = self.dec.decode(frame, max_frame_size)
+ else:
+ pcm = frame # PCM 直接使用
+ with _lock:
+ _buf.extend(pcm)
+ except queue.Empty:
+ pass
+ except Exception as e:
+ self._log(f'[解码错误] {e}')
+
+ threading.Thread(target=_feed_loop, daemon=True).start()
+
+ def _pcm_stream():
+ num_frames = yield b"" # 预激,接收首次 send(num_frames)
+ while True:
+ if self._stop.is_set():
+ return # 生成器结束,miniaudio 停止播放并退出 dev.start()
+ needed = num_frames * bytes_per_frame
+ with _lock:
+ if len(_buf) >= needed:
+ chunk = bytes(_buf[:needed])
+ del _buf[:needed]
+ else:
+ chunk = bytes(_buf) + b'\x00' * (needed - len(_buf))
+ _buf.clear()
+ num_frames = yield chunk
+
+ gen = _pcm_stream()
+ next(gen)
+ with miniaudio.PlaybackDevice(
+ output_format=miniaudio.SampleFormat.SIGNED16,
+ nchannels=self.tts_channels,
+ sample_rate=self.tts_sample_rate,
+ ) as dev:
+ dev.start(gen)
+ self._stop.wait()
+
+ # ── 文件模式:从音频文件读取并发送 ASR 帧 ──────────────────────────────────
+
+ def _file_record_thread(self, audio_file: str):
+ """读取音频文件(自动重采样到 16kHz mono),以实时速率流式发送给 ASR。"""
+ self.handshake_done.wait()
+ try:
+ decoded = miniaudio.decode_file(
+ audio_file,
+ output_format=miniaudio.SampleFormat.SIGNED16,
+ nchannels=CHANNELS,
+ sample_rate=SAMPLE_RATE,
+ )
+ except Exception as e:
+ self._log(f"[文件读取失败] {e}")
+ self._stop.set()
+ return
+
+ pcm_data = bytes(decoded.samples)
+ frame_bytes = FRAME_SAMPLES * CHANNELS * 2 # s16le,每帧字节数
+ duration_s = len(pcm_data) / 2 / SAMPLE_RATE
+
+ sid = str(uuid.uuid4())
+ self.ws.send(json.dumps({"type": "listen", "state": "start", "session_id": sid}))
+ self._log("─" * 40)
+ self._log(f"[文件模式] 发送: {os.path.basename(audio_file)} ({duration_s:.1f}s)")
+
+ offset = 0
+ while offset < len(pcm_data) and not self._stop.is_set():
+ chunk = pcm_data[offset:offset + frame_bytes]
+ if len(chunk) < frame_bytes:
+ chunk = chunk + b'\x00' * (frame_bytes - len(chunk))
+ if self.audio_fmt == "opus":
+ chunk = bytes(self.enc.encode(chunk, FRAME_SAMPLES))
+ self.ws.send(chunk, opcode=websocket.ABNF.OPCODE_BINARY)
+ offset += frame_bytes
+ time.sleep(FRAME_DURATION_MS / 1000.0) # 模拟实时节奏
+
+ self.ws.send(json.dumps({"type": "listen", "state": "end"}))
+ self._log("[文件模式] 发送完毕,等待识别...")
+
+ # ── 麦克风录音:持续采集,_is_listening 控制是否发送 ─────────────────────
+
+ def _record_thread(self):
+ """持续录制麦克风音频,仅在 _is_listening=True 时向服务端发送帧。"""
+ self.handshake_done.wait()
+
+ stream = self.pa.open(
+ format=PYAUDIO_FORMAT, channels=CHANNELS,
+ rate=SAMPLE_RATE, input=True,
+ input_device_index=self._audio_device,
+ frames_per_buffer=FRAME_SAMPLES,
+ )
+ try:
+ while not self._stop.is_set():
+ try:
+ pcm_bytes = stream.read(FRAME_SAMPLES, exception_on_overflow=False)
+ except OSError:
+ break
+ if not self._is_listening:
+ continue
+ if self.audio_fmt == "opus":
+ frame = bytes(self.enc.encode(pcm_bytes, FRAME_SAMPLES))
+ else:
+ frame = pcm_bytes
+ try:
+ self.ws.send(frame, opcode=websocket.ABNF.OPCODE_BINARY)
+ except Exception:
+ break
+ finally:
+ stream.stop_stream()
+ stream.close()
+
+ # ── 主入口 ────────────────────────────────────────────────────────────────
+
+ def run(self, audio_file: str = ""):
+ self._file_mode = bool(audio_file)
+ ws = websocket.WebSocketApp(
+ self.url,
+ on_open=self.on_open,
+ on_message=self.on_message,
+ on_error=self.on_error,
+ on_close=self.on_close,
+ )
+ self.ws = ws
+
+ playback_t = threading.Thread(target=self._playback_thread, daemon=True)
+ playback_t.start()
+ if audio_file:
+ threading.Thread(target=self._file_record_thread, args=(audio_file,), daemon=True).start()
+ else:
+ threading.Thread(target=self._record_thread, daemon=True).start()
+ threading.Thread(target=self._key_thread, daemon=True).start()
+
+ try:
+ ws.run_forever()
+ except KeyboardInterrupt:
+ pass
+ self._stop.set()
+ playback_t.join(timeout=1.0)
+ self.pa.terminate()
+
+
+def main():
+ if platform.system() != "Windows":
+ # 压制 ALSA/PortAudio C 层噪声(不影响 Python stderr)
+ _devnull = os.open(os.devnull, os.O_WRONLY)
+ os.dup2(_devnull, 2)
+ os.close(_devnull)
+
+ parser = argparse.ArgumentParser(description="picoclaw-voice 演示客户端")
+ parser.add_argument("--url", default="ws://127.0.0.1:18765/xiaozhi/v1/",
+ help="服务端 WebSocket 地址")
+ parser.add_argument("--audio-file", default="",
+ help="音频文件路径(代替麦克风,自动发送后退出)")
+ args = parser.parse_args()
+
+ PicoclawVoiceClient(args.url).run(audio_file=args.audio_file)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/cmd/picoclaw-voice/demo/requirements.txt b/cmd/picoclaw-voice/demo/requirements.txt
new file mode 100644
index 000000000..d60113d38
--- /dev/null
+++ b/cmd/picoclaw-voice/demo/requirements.txt
@@ -0,0 +1,4 @@
+websocket-client>=1.6.0
+pyaudio>=0.2.14
+opuslib>=3.0.1
+miniaudio>=1.2
diff --git a/cmd/picoclaw-voice/go.mod b/cmd/picoclaw-voice/go.mod
new file mode 100644
index 000000000..f1ed8584b
--- /dev/null
+++ b/cmd/picoclaw-voice/go.mod
@@ -0,0 +1,39 @@
+module github.com/sipeed/picoclaw/picoclaw-voice
+
+go 1.25.7
+
+require (
+ github.com/caarlos0/env/v11 v11.3.1
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/pion/opus v0.0.0-20260219180131-abe26becac00
+ github.com/sipeed/picoclaw v0.0.0
+)
+
+require (
+ github.com/adhocore/gronx v1.19.6 // indirect
+ github.com/anthropics/anthropic-sdk-go v1.22.1 // indirect
+ github.com/github/copilot-sdk/go v0.1.23 // indirect
+ github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
+ github.com/h2non/filetype v1.1.3 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modelcontextprotocol/go-sdk v1.3.1 // indirect
+ github.com/openai/openai-go/v3 v3.22.0 // indirect
+ github.com/rs/zerolog v1.34.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.3 // indirect
+ github.com/tidwall/gjson v1.18.0 // indirect
+ github.com/tidwall/match v1.2.0 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+// 指向本地 picoclaw fork 根目录
+replace github.com/sipeed/picoclaw => ../../
diff --git a/cmd/picoclaw-voice/go.sum b/cmd/picoclaw-voice/go.sum
new file mode 100644
index 000000000..29dacfe6e
--- /dev/null
+++ b/cmd/picoclaw-voice/go.sum
@@ -0,0 +1,79 @@
+github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
+github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
+github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
+github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
+github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
+github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
+github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
+github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
+github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
+github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
+github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
+github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/pion/opus v0.0.0-20260219180131-abe26becac00 h1:+PXsZ5OSLoyzPdzKvr8X/C4WtIWk7GbAAIKZOrFV744=
+github.com/pion/opus v0.0.0-20260219180131-abe26becac00/go.mod h1:t5Xog2n682JnawoykACE6nKVmupFvmJvkpM7x6bTv6g=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
+github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/picoclaw-voice/handler.go b/cmd/picoclaw-voice/handler.go
new file mode 100644
index 000000000..b08281d0c
--- /dev/null
+++ b/cmd/picoclaw-voice/handler.go
@@ -0,0 +1,744 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/asr"
+ "github.com/sipeed/picoclaw/pkg/tts"
+)
+
+// ---- session ----
+
+// deviceRegistry 维护 device_id → 活跃 session 的映射。
+// 同一 device_id 的新连接到来时,旧连接被立即关闭(last-write-wins)。
+type deviceRegistry struct {
+ mu sync.Mutex
+ active map[string]*session
+}
+
+func newDeviceRegistry() *deviceRegistry {
+ return &deviceRegistry{active: make(map[string]*session)}
+}
+
+// register 注册新 session,并关闭同设备的旧连接。
+func (r *deviceRegistry) register(deviceID string, s *session) {
+ r.mu.Lock()
+ old, exists := r.active[deviceID]
+ r.active[deviceID] = s
+ r.mu.Unlock()
+ if exists && old != s {
+ log.Printf("picoclaw-voice: device %s reconnected, evicting old session", deviceID)
+ old.connCancel()
+ old.conn.Close()
+ }
+}
+
+// unregister 在 session 退出时清理(仅当仍是活跃 session 时)。
+func (r *deviceRegistry) unregister(deviceID string, s *session) {
+ r.mu.Lock()
+ if r.active[deviceID] == s {
+ delete(r.active, deviceID)
+ }
+ r.mu.Unlock()
+}
+
+// asrChunk 是实时 ASR 音频帧,isLast=true 表示结束帧。
+// frame 内容格式与协商的 audioFmt 一致(常见为 PCM 字节)。
+type asrChunk struct {
+ frame []byte
+ isLast bool
+}
+
+// audioItem 是 TTS 正在合成的一句话,frameCh 流式传递音频帧。
+// 阶段 B 合成时边回调边写入 frameCh,close(frameCh) 表示本句合成完毕。
+type audioItem struct {
+ text string
+ frameCh chan []byte // 流式音频帧,close 表示本句结束
+}
+
+type session struct {
+ conn *websocket.Conn
+ writeMu sync.Mutex
+ asr asr.Provider
+ tts tts.Provider
+ agentLoop *agent.AgentLoop
+ registry *deviceRegistry
+ // connCancel 由 deviceRegistry.register 在设备重连时调用,强制关闭本连接。
+ connCancel context.CancelFunc
+ // llmSessOverride:通过 PICOCLAW_VOICE_OWNER_ID 设置的 owner_id(如 "picoclaw"),优先级最高。
+ // 同时作为 connID 基础和强制 memoryID,确保所有渠道共享同一记忆上下文(跨渠道记忆统一)。
+ llmSessOverride string
+ // deviceID:从 hello 消息提取的设备标识(MAC 或 device_id)
+ deviceID string
+ // connID:WebSocket 连接级 ID,服务端在 hello 时生成并回传客户端,客户端不关心。
+ // 用于标识一条长连接,设备重连时更新。
+ connID string
+ // turnID:问题级 ID,由客户端在 VAD start(listen.start)时生成并下发。
+ // 贯穿整个问题生命周期:VAD start → ASR → LLM → TTS stop。
+ turnID string
+ // memoryID:LLM 多轮记忆 key,由客户端在 listen.start 时指定。
+ // 同一 memoryID 的问题共享对话历史;客户端可按场景/用户自由切换。
+ // 未携带时退化为 deviceID,deviceID 也无时退化为 connID。
+ memoryID string
+
+ // audioFmt 是在 hello 阶段由 ASR provider 声明并写入 helloReply 的格式。
+ // handleAudio 按此格式进行轻量校验,provider 直接按此格式使用。
+ audioFmt asr.AudioFormat
+ audioBuf [][]byte
+
+ // asrSess 和 asrFeedCh 仅在使用实时 ASR(RealtimeProvider)时非 nil。
+ // listen start 时建连,handleAudio 推送帧,listen end 时发结束帧。
+ asrSess asr.StreamingSession
+ asrFeedCh chan asrChunk
+
+ // 当前语音交互的总取消函数
+ cancelMu sync.Mutex
+ cancel context.CancelFunc
+}
+
+func newSession(conn *websocket.Conn, asrProv asr.Provider, ttsProv tts.Provider, al *agent.AgentLoop, llmSessOverride string, reg *deviceRegistry) *session {
+ _, cancel := context.WithCancel(context.Background())
+ return &session{
+ conn: conn,
+ asr: asrProv,
+ tts: ttsProv,
+ agentLoop: al,
+ registry: reg,
+ connCancel: cancel,
+ llmSessOverride: llmSessOverride,
+ }
+}
+
+func (s *session) run() {
+ defer s.connCancel()
+ defer s.conn.Close()
+ defer func() {
+ if s.deviceID != "" {
+ s.registry.unregister(s.deviceID, s)
+ }
+ }()
+ for {
+ msgType, data, err := s.conn.ReadMessage()
+ if err != nil {
+ log.Printf("picoclaw-voice: session %s (%s) read error: %v", s.connID, s.deviceID, err)
+ return
+ }
+ switch msgType {
+ case websocket.TextMessage:
+ s.handleText(data)
+ case websocket.BinaryMessage:
+ s.handleAudio(data)
+ }
+ }
+}
+
+func (s *session) handleText(data []byte) {
+ var base struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(data, &base); err != nil {
+ return
+ }
+
+ switch base.Type {
+ case "hello":
+ var msg helloMsg
+ if err := json.Unmarshal(data, &msg); err == nil {
+ s.deviceID = msg.DeviceID
+ }
+ // hello:服务端生成连接级 connID 并回传客户端(客户端不关心,仅用于日志关联)
+ if s.connID == "" {
+ if s.llmSessOverride != "" {
+ s.connID = s.llmSessOverride
+ } else {
+ s.connID = uuid.New().String()
+ }
+ log.Printf("picoclaw-voice: conn_id=%s device=%s", s.connID, s.deviceID)
+ }
+ // 注册设备:驱逐同设备的旧连接(重连场景)
+ if s.deviceID != "" {
+ s.registry.register(s.deviceID, s)
+ }
+ // 从 ASR/TTS provider 获取各自格式,写入 helloReply 协商给客户端
+ s.audioFmt = s.asr.AudioFormat()
+ s.writeText(helloReply(s.connID, s.audioFmt, s.tts.AudioFormat()))
+
+ case "listen":
+ var msg listenMsg
+ if err := json.Unmarshal(data, &msg); err != nil {
+ return
+ }
+ switch msg.State {
+ case "start":
+ s.audioBuf = s.audioBuf[:0] // 重置帧缓冲(保留 slice 底层内存)
+ // turn_id 由客户端在 listen start 时携带(session_id 字段);
+ // 未携带时服务端生成 UUID,确保每轮可独立追踪。
+ if msg.SessionID != "" {
+ s.turnID = msg.SessionID
+ } else {
+ s.turnID = uuid.New().String()
+ }
+ log.Printf("picoclaw-voice: turn_id=%s", s.turnID)
+ // memoryID 决定本轮 LLM 使用哪份多轮对话历史:
+ // PICOCLAW_VOICE_OWNER_ID(全局覆盖)> listen.memory_id [picoclaw 扩展] > device_id > connection_id
+ if s.llmSessOverride != "" {
+ s.memoryID = s.llmSessOverride
+ } else if msg.MemoryID != "" {
+ s.memoryID = msg.MemoryID
+ } else if s.deviceID != "" {
+ s.memoryID = s.deviceID
+ } else {
+ s.memoryID = s.connID
+ }
+ // 取消旧流水线,关闭旧实时 ASR 会话
+ s.cancelMu.Lock()
+ if s.cancel != nil {
+ s.cancel()
+ s.cancel = nil
+ }
+ s.cancelMu.Unlock()
+ if s.asrSess != nil {
+ s.asrSess.Close()
+ s.asrSess = nil
+ s.asrFeedCh = nil
+ }
+ // 实时 ASR 模式:listen start 时就建连,音频帧实时推送
+ if rp, ok := s.asr.(asr.RealtimeProvider); ok {
+ pipeCtx, pipeCancel := context.WithCancel(context.Background())
+ s.cancelMu.Lock()
+ s.cancel = pipeCancel
+ s.cancelMu.Unlock()
+ asrCtx, asrCancel := context.WithTimeout(pipeCtx, 30*time.Second)
+ sess, err := rp.OpenSession(asrCtx, func(text string, final bool) {
+ state := "recognizing"
+ if final {
+ state = "stop"
+ }
+ s.writeText(newStt(text, state))
+ })
+ if err != nil {
+ asrCancel()
+ pipeCancel()
+ s.cancelMu.Lock()
+ s.cancel = nil
+ s.cancelMu.Unlock()
+ log.Printf("picoclaw-voice: open asr session: %v", err)
+ } else {
+ feedCh := make(chan asrChunk, 64)
+ s.asrSess = sess
+ s.asrFeedCh = feedCh
+ turnID, memoryID := s.turnID, s.memoryID
+ go func() {
+ defer asrCancel()
+ s.processStreamSpeech(pipeCtx, sess, feedCh, time.Now(), turnID, memoryID)
+ // 流水线结束,清理引用。handleAudio 看到 nil 后切换到批量缓冲模式。
+ // 轻微竞争可接受:最多丢个别帧,下一轮 listen.start 会重新赋值。
+ s.asrSess = nil
+ s.asrFeedCh = nil
+ }()
+ }
+ }
+ case "end", "stop":
+ if s.asrSess != nil {
+ // 实时模式:发送结束帧,processStreamSpeech 等待最终 ASR 结果
+ select {
+ case s.asrFeedCh <- asrChunk{isLast: true}:
+ default:
+ log.Printf("picoclaw-voice: asr feed channel full on finalize")
+ }
+ s.asrSess = nil
+ s.asrFeedCh = nil
+ } else {
+ // 批量模式:VAD 结束后一次性 ASR
+ s.cancelMu.Lock()
+ if s.cancel != nil {
+ s.cancel()
+ s.cancel = nil
+ }
+ s.cancelMu.Unlock()
+ buf := make([][]byte, len(s.audioBuf))
+ copy(buf, s.audioBuf)
+ go s.processSpeech(buf)
+ }
+ }
+
+ case "abort":
+ // 关闭实时 ASR 会话(如果有)
+ if s.asrSess != nil {
+ s.asrSess.Close()
+ s.asrSess = nil
+ s.asrFeedCh = nil
+ }
+ s.cancelMu.Lock()
+ if s.cancel != nil {
+ s.cancel()
+ s.cancel = nil
+ }
+ s.cancelMu.Unlock()
+ // 通知客户端立即清空播放缓冲(触发 miniaudio stop+restart)
+ s.writeText(newTts("abort", ""))
+ }
+}
+
+// validateAudioFrame 轻量校验帧格式是否与协商结果一致。
+// 安全防御:客户端应遵守 helloReply 下发的格式,不符则记 warn 并丢弃。
+func (s *session) validateAudioFrame(data []byte) bool {
+ if len(data) == 0 {
+ log.Printf("picoclaw-voice: empty audio frame, dropping")
+ return false
+ }
+ switch s.audioFmt.Codec {
+ case "pcm":
+ if len(data)%2 != 0 {
+ log.Printf("picoclaw-voice: pcm frame size=%d not 16-bit aligned, dropping", len(data))
+ return false
+ }
+ }
+ return true
+}
+
+func (s *session) handleAudio(data []byte) {
+ // 轻量校验帧格式(格式已在 hello 阶段协商,防御客户端实现缺陷)
+ if !s.validateAudioFrame(data) {
+ return
+ }
+ frame := make([]byte, len(data))
+ copy(frame, data)
+ if s.asrFeedCh != nil {
+ // 实时模式:直接推送给 ASR,不缓冲
+ select {
+ case s.asrFeedCh <- asrChunk{frame: frame}:
+ default:
+ log.Printf("picoclaw-voice: asr feed channel full, dropping frame")
+ }
+ } else {
+ // 批量模式:累积到 audioBuf,等 VAD end 后一次性 ASR
+ s.audioBuf = append(s.audioBuf, frame)
+ }
+}
+
+// processSpeech: ASR → LLM → TTS 三阶段并发流水线。
+//
+// 阶段 A (goroutine): LLM 流式推理,按断句符切句,写入 sentCh。
+// 阶段 B (goroutine): 从 sentCh 读句子,调用 TTS 合成,写入 readyCh。
+//
+// B 与 A 并发:LLM 生下一句时,TTS 已在合成当前句。
+//
+// 阶段 C (main): 从 readyCh 按序读音频帧,推给客户端。
+//
+// C 发音频时,B 已在合成下一句,消除句间空档。
+func (s *session) processSpeech(frames [][]byte) {
+ reqStart := time.Now()
+ log.Printf("picoclaw-voice: processSpeech: frames=%d", len(frames))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ s.cancelMu.Lock()
+ s.cancel = cancel
+ s.cancelMu.Unlock()
+ defer cancel()
+
+ // 1. ASR(独立 20s 超时,不影响后续 LLM/TTS 管道)
+ asrCtx, asrCancel := context.WithTimeout(ctx, 20*time.Second)
+ defer asrCancel()
+
+ var finalText string
+ var asrErr error
+ if sp, ok := s.asr.(asr.StreamingProvider); ok {
+ // 流式 ASR:每次文本变化都发给客户端(recognizing),最终结果发 stop
+ asrErr = sp.TranscribeStream(asrCtx, frames, func(text string, final bool) {
+ state := "recognizing"
+ if final {
+ state = "stop"
+ finalText = text
+ }
+ s.writeText(newStt(text, state))
+ })
+ } else {
+ finalText, asrErr = s.asr.Transcribe(asrCtx, frames)
+ if asrErr == nil && strings.TrimSpace(finalText) != "" {
+ s.writeText(newStt(finalText, "stop"))
+ }
+ }
+
+ if asrErr != nil {
+ if ctx.Err() == nil {
+ log.Printf("picoclaw-voice: asr: %v", asrErr)
+ }
+ // 通知客户端复位 FSM(PROCESSING → IDLE)
+ s.writeText(newTts("stop", ""))
+ return
+ }
+ if strings.TrimSpace(finalText) == "" {
+ // 静默或未识别:通知客户端复位 FSM(PROCESSING → IDLE)
+ s.writeText(newTts("stop", ""))
+ return
+ }
+ log.Printf("picoclaw-voice: asr=%q latency=%dms", finalText, time.Since(reqStart).Milliseconds())
+
+ turnID := s.turnID
+ if turnID == "" {
+ turnID = s.connID
+ }
+ memoryID := s.memoryID
+ if memoryID == "" {
+ memoryID = s.connID
+ }
+ s.runPipeline(ctx, finalText, reqStart, turnID, memoryID)
+}
+
+// runPipeline 执行 LLM → TTS 三阶段并发流水线。
+// 由 processSpeech(批量 ASR)和 processStreamSpeech(实时 ASR)共同调用。
+func (s *session) runPipeline(ctx context.Context, text string, reqStart time.Time, turnID, memoryID string) {
+ sentCh := make(chan string, 8) // 阶段 A → B
+ readyCh := make(chan audioItem, 4) // 阶段 B → C(最多 4 句并发预合成)
+
+ // 阶段 A: LLM 流式 → sentCh
+ go func() {
+ defer close(sentCh)
+ if err := s.streamLLM(ctx, turnID, memoryID, text, reqStart, func(sentence string) {
+ select {
+ case sentCh <- sentence:
+ case <-ctx.Done():
+ }
+ }); err != nil {
+ // ctx.Err() != nil 表示主动 abort/重请求;err == Canceled 是 watchdog 取消(已自行记录)
+ if ctx.Err() == nil && err != context.Canceled && err != context.DeadlineExceeded {
+ log.Printf("picoclaw-voice: llm: %v", err)
+ }
+ }
+ }()
+
+ // 阶段 B: sentCh → TTS 并发合成 → readyCh(流式)
+ // 每句起一个独立 goroutine 合成,Stage B 不阻塞等待当前句合成完成,
+ // 立刻读取下一句并开始其合成——多句 HTTP 请求并发进行。
+ // Stage C 仍按 readyCh 顺序消费,保证播放有序。
+ // readyCh 缓冲控制最大并发预合成句数(buffer=4 表示最多超前合成 4 句)。
+ go func() {
+ defer close(readyCh)
+ for {
+ select {
+ case sentence, ok := <-sentCh:
+ if !ok {
+ return
+ }
+ frameCh := make(chan []byte, 32)
+ select {
+ case readyCh <- audioItem{text: sentence, frameCh: frameCh}:
+ case <-ctx.Done():
+ close(frameCh)
+ return
+ }
+ // 异步合成:Stage B 立即处理下一句,勿阻塞
+ go func(sentence string, frameCh chan []byte) {
+ defer close(frameCh)
+ synthErr := s.tts.SynthesizeFrames(ctx, sentence, "", func(frame []byte) {
+ select {
+ case frameCh <- frame:
+ case <-ctx.Done():
+ }
+ })
+ if synthErr != nil && ctx.Err() == nil {
+ log.Printf("picoclaw-voice: tts sentence %q: %v", sentence, synthErr)
+ }
+ }(sentence, frameCh)
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ // 阶段 C: readyCh → 音频帧 → WebSocket(保序)
+ // 每句收到 audioItem 后立即发 sentence_start,然后从 frameCh 流式转发帧。
+ firstItem := true
+ for item := range readyCh {
+ if firstItem {
+ // tts.start 触发客户端 FSM: PROCESSING → SPEAKING
+ s.writeText(newTts("start", ""))
+ firstItem = false
+ }
+ s.writeText(newTts("sentence_start", item.text))
+ for frame := range item.frameCh {
+ if err := s.writeBinary(frame); err != nil {
+ log.Printf("picoclaw-voice: send audio frame: %v", err)
+ }
+ }
+ s.writeText(newTts("sentence_end", ""))
+ }
+
+ s.writeText(newTts("stop", ""))
+}
+
+// processStreamSpeech 处理实时 ASR 流:并发发送音频帧 + 等待最终识别结果,然后运行 LLM/TTS 流水线。
+func (s *session) processStreamSpeech(ctx context.Context, sess asr.StreamingSession, feedCh <-chan asrChunk, reqStart time.Time, turnID, memoryID string) {
+ defer sess.Close()
+
+ // 并发发送音频帧给 ASR 服务
+ sendDone := make(chan struct{})
+ go func() {
+ defer close(sendDone)
+ for {
+ select {
+ case chunk, ok := <-feedCh:
+ if !ok {
+ return
+ }
+ if err := sess.SendAudio(chunk.frame, chunk.isLast); err != nil {
+ if ctx.Err() == nil {
+ log.Printf("picoclaw-voice: asr send: %v", err)
+ }
+ return
+ }
+ if chunk.isLast {
+ return
+ }
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ // 等待 ASR 最终识别结果
+ text, err := sess.Wait(ctx)
+ <-sendDone
+
+ if err != nil {
+ if ctx.Err() == nil && !errors.Is(err, asr.ErrSessionClosed) {
+ log.Printf("picoclaw-voice: asr: %v", err)
+ s.writeText(newTts("stop", ""))
+ }
+ return
+ }
+ if strings.TrimSpace(text) == "" {
+ s.writeText(newTts("stop", ""))
+ return
+ }
+ log.Printf("picoclaw-voice: asr=%q latency=%dms", text, time.Since(reqStart).Milliseconds())
+
+ s.runPipeline(ctx, text, reqStart, turnID, memoryID)
+}
+
+// streamLLM 直接调用 AgentLoop 流式推理,按句触发 onSentence 回调。
+// 同时支持 thinking 模式(...)和普通模式:
+// - thinking 块内容不送 TTS,仅记录耗时日志
+// - thinking 块之外的内容按断句符触发 TTS
+//
+// reqStart 用于记录 LLM 首 token 延迟(含 ASR 时间,为端到端指标)。
+func (s *session) streamLLM(ctx context.Context, turnID, memoryID, userText string, reqStart time.Time, onSentence func(string)) error {
+ llmStart := time.Now()
+ log.Printf("picoclaw-voice: llm request: turn=%s memory=%s text=%q", turnID, memoryID, userText)
+ const startTag = ""
+ const endTag = ""
+
+ // 首 token 超时 30s;之后每个 token 间隔超时 8s(每收到 token 自动重置)
+ const firstTokenTimeout = 30 * time.Second
+ const interTokenTimeout = 8 * time.Second
+ llmCtx, llmCancel := context.WithCancel(ctx)
+ defer llmCancel()
+ tokenActivity := make(chan struct{}, 1)
+ go func() {
+ firstToken := true
+ t := time.NewTimer(firstTokenTimeout)
+ defer t.Stop()
+ for {
+ select {
+ case <-llmCtx.Done():
+ return
+ case <-tokenActivity:
+ if firstToken {
+ firstToken = false
+ }
+ if !t.Stop() {
+ select {
+ case <-t.C:
+ default:
+ }
+ }
+ t.Reset(interTokenTimeout)
+ case <-t.C:
+ if firstToken {
+ log.Printf("picoclaw-voice: llm timeout: no first token after %s", firstTokenTimeout)
+ } else {
+ log.Printf("picoclaw-voice: llm timeout: no token for %s", interTokenTimeout)
+ }
+ llmCancel()
+ return
+ }
+ }
+ }()
+
+ var normalBuf strings.Builder
+ // thinkTail 保存 thinking 块末尾最多 len(endTag)-1 个字节,
+ // 用于跨 token 边界检测 而无需缓存完整 thinking 内容。
+ thinkTail := make([]byte, 0, len(endTag)-1)
+ inThinking := false
+ var thinkStart time.Time
+ firstContent := reqStart // 非零时尚未记录首个实际内容 token 延迟
+
+ flushNormal := func() {
+ text := normalBuf.String()
+ if idx := lastSentenceBreak(text); idx > 0 {
+ sentence := text[:idx]
+ remaining := text[idx:]
+ // 先更新缓冲,再发送(onSentence 可能阻塞)
+ normalBuf.Reset()
+ normalBuf.WriteString(remaining)
+ if trimmed := strings.TrimSpace(sentence); trimmed != "" {
+ log.Printf("picoclaw-voice: llm sentence: %q", trimmed)
+ // [picoclaw 扩展] llm 消息将断句后的 LLM 输出同步推送到客户端(标准 xiaozhi 无此消息类型)
+ s.writeText(newLlmText(trimmed))
+ onSentence(trimmed)
+ }
+ }
+ }
+
+ onToken := func(token string) {
+ // token 到达,重置 watchdog 定时器
+ select {
+ case tokenActivity <- struct{}{}:
+ default:
+ }
+ // token 可能跨多个 tag 边界,用循环处理完
+ for token != "" {
+ if inThinking {
+ // 用滑动窗口检测 ,不存储完整 thinking 内容
+ scan := string(thinkTail) + token
+ if idx := strings.Index(scan, endTag); idx >= 0 {
+ durationMs := time.Since(thinkStart).Milliseconds()
+ log.Printf("picoclaw-voice: thinking=%dms", durationMs)
+ s.writeText(newLlmThinkingEnd(durationMs))
+ after := scan[idx+len(endTag):]
+ thinkTail = thinkTail[:0]
+ inThinking = false
+ token = after // 继续处理 之后的内容
+ } else {
+ // 更新尾部窗口(保留最多 len(endTag)-1 字节)
+ if len(scan) >= len(endTag)-1 {
+ thinkTail = []byte(scan[len(scan)-(len(endTag)-1):])
+ } else {
+ thinkTail = []byte(scan)
+ }
+ token = ""
+ }
+ } else {
+ normalBuf.WriteString(token)
+ token = ""
+ combined := normalBuf.String()
+ if idx := strings.Index(combined, startTag); idx >= 0 {
+ before := combined[:idx]
+ after := combined[idx+len(startTag):]
+ normalBuf.Reset()
+ normalBuf.WriteString(before)
+ // before 非空时记录首内容延迟(thinking 前已有实际输出)
+ if !firstContent.IsZero() && before != "" {
+ log.Printf("picoclaw-voice: llm_first_token latency=%dms", time.Since(firstContent).Milliseconds())
+ firstContent = time.Time{}
+ }
+ flushNormal()
+ inThinking = true
+ thinkStart = time.Now()
+ thinkTail = thinkTail[:0]
+ log.Printf("picoclaw-voice: llm thinking...")
+ s.writeText(newLlmThinkingStart())
+ token = after
+ } else {
+ // 普通内容:记录首 token 延迟并断句
+ if !firstContent.IsZero() && strings.TrimSpace(normalBuf.String()) != "" {
+ log.Printf("picoclaw-voice: llm_first_token latency=%dms", time.Since(firstContent).Milliseconds())
+ firstContent = time.Time{}
+ }
+ flushNormal()
+ }
+ }
+ }
+ }
+
+ if err := s.agentLoop.RunStreamAgentLoop(llmCtx, memoryID, userText, "voice", turnID, onToken); err != nil {
+ return err
+ }
+
+ // 处理最后剩余片段(thinking 块未正常关闭时跳过,避免把 thinking 内容送 TTS)
+ if !inThinking && normalBuf.Len() > 0 {
+ if trimmed := strings.TrimSpace(normalBuf.String()); trimmed != "" {
+ log.Printf("picoclaw-voice: llm sentence: %q", trimmed)
+ s.writeText(newLlmText(trimmed))
+ onSentence(trimmed)
+ }
+ }
+
+ log.Printf("picoclaw-voice: llm done: turn=%s latency=%dms", turnID, time.Since(llmStart).Milliseconds())
+ return nil
+}
+
+// lastSentenceBreak 返回字符串中最后一个句子边界之后的字节偏移,
+// 即 sentenceTerminators rune 之后第一个字节的位置。
+// 找不到时返回 0。
+//
+// 规则:
+// - 中文结束符(。!?)和换行符(\n)直接算句末。
+// - 英文 !? 后跟空格时才切(末尾不切,防止流式缓冲末尾误判)。
+// - 英文 . 仅当同时满足以下条件才切:
+// 1. 前一字符不是数字(排除版本号/小数点 1.25.7)
+// 2. 后一字符不是数字
+// 3. 后跟 "空格 + 大写字母"(典型新句子开头),而非末尾等待
+func lastSentenceBreak(s string) int {
+ idx := -1
+ bytes := []byte(s)
+ bytePos := 0
+ for _, r := range s {
+ rLen := utf8.RuneLen(r)
+ if strings.ContainsRune("。!?\n", r) {
+ // 中文结束符 / 换行:直接切
+ idx = bytePos + rLen
+ } else if strings.ContainsRune("!?", r) {
+ // 英文 !?:后跟空格时才切(末尾不切)
+ nextPos := bytePos + rLen
+ if nextPos < len(bytes) && (bytes[nextPos] == ' ' || bytes[nextPos] == '\t') {
+ idx = nextPos
+ }
+ } else if r == '.' {
+ // 英文句号:区分句末与版本号/小数点
+ // 切分条件:前一字符非数字 AND 后跟空格(不是数字开头的版本号)
+ nextPos := bytePos + rLen
+ prevIsDigit := bytePos > 0 && bytes[bytePos-1] >= '0' && bytes[bytePos-1] <= '9'
+ if !prevIsDigit && nextPos < len(bytes) && !(bytes[nextPos] >= '0' && bytes[nextPos] <= '9') {
+ if bytes[nextPos] == ' ' || bytes[nextPos] == '\t' {
+ idx = nextPos
+ }
+ }
+ // 末尾不切:等待下一个 token 确认是版本号还是真正句末
+ }
+ bytePos += rLen
+ }
+ if idx < 0 {
+ return 0
+ }
+ return idx
+}
+
+func (s *session) writeText(data []byte) error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ return s.conn.WriteMessage(websocket.TextMessage, data)
+}
+
+func (s *session) writeBinary(data []byte) error {
+ s.writeMu.Lock()
+ defer s.writeMu.Unlock()
+ return s.conn.WriteMessage(websocket.BinaryMessage, data)
+}
diff --git a/cmd/picoclaw-voice/handler_test.go b/cmd/picoclaw-voice/handler_test.go
new file mode 100644
index 000000000..c88dfd35e
--- /dev/null
+++ b/cmd/picoclaw-voice/handler_test.go
@@ -0,0 +1,234 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestLastSentenceBreak_Empty(t *testing.T) {
+ if got := lastSentenceBreak(""); got != 0 {
+ t.Errorf("got %d, want 0", got)
+ }
+}
+
+func TestLastSentenceBreak_NoTerminator(t *testing.T) {
+ if got := lastSentenceBreak("你好世界 hello world"); got != 0 {
+ t.Errorf("got %d, want 0", got)
+ }
+}
+
+func TestLastSentenceBreak_ChinesePeriod(t *testing.T) {
+ s := "你好。"
+ idx := lastSentenceBreak(s)
+ if idx != len(s) {
+ t.Errorf("got %d, want %d (end of string)", idx, len(s))
+ }
+}
+
+func TestLastSentenceBreak_MultipleTerminators(t *testing.T) {
+ // 应返回最后一个句子边界之后
+ s := "你好。再见!"
+ idx := lastSentenceBreak(s)
+ if idx != len(s) {
+ t.Errorf("got %d, want %d", idx, len(s))
+ }
+}
+
+func TestLastSentenceBreak_TrailingText(t *testing.T) {
+ // 断句后有剩余内容
+ s := "你好。还有更多内容"
+ // "你好。" = 9 bytes(每个汉字3字节,句号3字节)
+ wantIdx := len("你好。")
+ idx := lastSentenceBreak(s)
+ if idx != wantIdx {
+ t.Errorf("got %d, want %d", idx, wantIdx)
+ }
+ remaining := s[idx:]
+ if remaining != "还有更多内容" {
+ t.Errorf("remaining = %q, want '还有更多内容'", remaining)
+ }
+}
+
+func TestLastSentenceBreak_EnglishPeriod(t *testing.T) {
+ s := "Hello. World"
+ idx := lastSentenceBreak(s)
+ if idx != len("Hello.") {
+ t.Errorf("got %d, want %d", idx, len("Hello."))
+ }
+}
+
+func TestLastSentenceBreak_Newline(t *testing.T) {
+ s := "line one\nline two"
+ idx := lastSentenceBreak(s)
+ if idx != len("line one\n") {
+ t.Errorf("got %d, want %d", idx, len("line one\n"))
+ }
+}
+
+func TestLastSentenceBreak_MixedScript(t *testing.T) {
+ s := "你好!Hello.再见"
+ // '!' 是全角感叹号,直接切;英文 '.' 后跟汉字(非空格),不满足切分条件。
+ // 所以最后一个断点是 '!' 之后,即 "你好!" 的字节长度。
+ want := len("你好!")
+ if got := lastSentenceBreak(s); got != want {
+ t.Errorf("got %d, want %d", got, want)
+ }
+}
+
+func TestLastSentenceBreak_OnlyTerminator(t *testing.T) {
+ s := "。"
+ if got := lastSentenceBreak(s); got != len(s) {
+ t.Errorf("got %d, want %d", got, len(s))
+ }
+}
+
+// ---- thinking 状态机测试 ----
+// 通过 simulateTokens 模拟 onToken 回调,验证 thinking 块过滤和断句行为。
+
+func simulateTokens(tokens []string) (sentences []string, thinkingLogged bool) {
+ const startTag = ""
+ const endTag = ""
+
+ var normalBuf strings.Builder
+ thinkTail := make([]byte, 0, len(endTag)-1)
+ inThinking := false
+ gotThinking := false
+
+ flushNormal := func() {
+ text := normalBuf.String()
+ if idx := lastSentenceBreak(text); idx > 0 {
+ sentences = append(sentences, text[:idx])
+ remaining := text[idx:]
+ normalBuf.Reset()
+ normalBuf.WriteString(remaining)
+ }
+ }
+
+ onToken := func(token string) {
+ for token != "" {
+ if inThinking {
+ scan := string(thinkTail) + token
+ if idx := strings.Index(scan, endTag); idx >= 0 {
+ gotThinking = true
+ after := scan[idx+len(endTag):]
+ thinkTail = thinkTail[:0]
+ inThinking = false
+ token = after
+ } else {
+ if len(scan) >= len(endTag)-1 {
+ thinkTail = []byte(scan[len(scan)-(len(endTag)-1):])
+ } else {
+ thinkTail = []byte(scan)
+ }
+ token = ""
+ }
+ } else {
+ normalBuf.WriteString(token)
+ token = ""
+ combined := normalBuf.String()
+ if idx := strings.Index(combined, startTag); idx >= 0 {
+ before := combined[:idx]
+ after := combined[idx+len(startTag):]
+ normalBuf.Reset()
+ normalBuf.WriteString(before)
+ flushNormal()
+ inThinking = true
+ thinkTail = thinkTail[:0]
+ token = after
+ } else {
+ flushNormal()
+ }
+ }
+ }
+ }
+
+ for _, t := range tokens {
+ onToken(t)
+ }
+ // 处理剩余
+ if !inThinking && normalBuf.Len() > 0 {
+ sentences = append(sentences, normalBuf.String())
+ }
+ return sentences, gotThinking
+}
+
+func TestThinking_NoThinkTag(t *testing.T) {
+ // 非 thinking 模式:所有内容正常断句
+ tokens := []string{"你好,", "我是 AI。", "有什么可以帮您?"}
+ sentences, thinking := simulateTokens(tokens)
+ if thinking {
+ t.Error("should not detect thinking")
+ }
+ // "我是 AI。" 和 "有什么可以帮您?" 各触发一次断句
+ if len(sentences) != 2 {
+ t.Errorf("want 2 sentences, got %d: %v", len(sentences), sentences)
+ }
+ if sentences[0] != "你好,我是 AI。" {
+ t.Errorf("sentences[0] = %q", sentences[0])
+ }
+ if sentences[1] != "有什么可以帮您?" {
+ t.Errorf("sentences[1] = %q", sentences[1])
+ }
+}
+
+func TestThinking_ThinkBlockSkipped(t *testing.T) {
+ // thinking 模式:... 内容不送 TTS
+ tokens := []string{"让我想一想。", "你好!"}
+ sentences, thinking := simulateTokens(tokens)
+ if !thinking {
+ t.Error("should detect thinking")
+ }
+ if len(sentences) != 1 {
+ t.Errorf("want 1 sentence, got %d: %v", len(sentences), sentences)
+ }
+ if sentences[0] != "你好!" {
+ t.Errorf("sentence[0] = %q", sentences[0])
+ }
+}
+
+func TestThinking_TagSplitAcrossTokens(t *testing.T) {
+ // 跨 token 边界
+ tokens := []string{"thinking", "回答。"}
+ sentences, thinking := simulateTokens(tokens)
+ if !thinking {
+ t.Error("should detect thinking")
+ }
+ if len(sentences) != 1 || sentences[0] != "回答。" {
+ t.Errorf("sentences = %v", sentences)
+ }
+}
+
+func TestThinking_ContentBeforeThink(t *testing.T) {
+ // 前已有正常内容
+ tokens := []string{"先说一句。思考中然后继续。"}
+ sentences, thinking := simulateTokens(tokens)
+ if !thinking {
+ t.Error("should detect thinking")
+ }
+ if len(sentences) != 2 {
+ t.Fatalf("want 2 sentences, got %d: %v", len(sentences), sentences)
+ }
+ if sentences[0] != "先说一句。" {
+ t.Errorf("sentences[0] = %q", sentences[0])
+ }
+ if sentences[1] != "然后继续。" {
+ t.Errorf("sentences[1] = %q", sentences[1])
+ }
+}
+
+func TestThinking_MultipleNewlinesInsideThink(t *testing.T) {
+ // thinking 块内大量换行不触发断句
+ tokens := strings.Split("\n\n\n很多换行\n\n\n好的。", "")
+ sentences, thinking := simulateTokens(tokens)
+ if !thinking {
+ t.Error("should detect thinking")
+ }
+ if len(sentences) != 1 || sentences[0] != "好的。" {
+ t.Errorf("sentences = %v", sentences)
+ }
+}
diff --git a/cmd/picoclaw-voice/main.go b/cmd/picoclaw-voice/main.go
new file mode 100644
index 000000000..4b773b9dd
--- /dev/null
+++ b/cmd/picoclaw-voice/main.go
@@ -0,0 +1,231 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// picoclaw-voice: xiaozhi WebSocket gateway — Opus↔ASR→LLM→TTS→Opus pipeline.
+// 通过 xiaozhi-esp32 协议与客户端通信,内部调用 ASR/LLM/TTS 三方服务。
+//
+// 配置通过环境变量注入(见 Config 结构体):
+// PICOCLAW_VOICE_LISTEN 监听地址,默认 :8765
+// PICOCLAW_VOICE_OWNER_ID 固定 owner_id,多设备共享同一份记忆,默认空(per-device 独立记忆)
+// PICOCLAW_CONFIG picoclaw config.json 路径,默认 ~/.picoclaw/config.json
+// PICOCLAW_HOME picoclaw home 目录,默认 ~/.picoclaw
+//
+// 通用供应商选择:
+// PICOCLAW_VOICE_ASR_PROVIDER ASR 供应商,可选 doubao | funasr,默认 doubao
+// PICOCLAW_VOICE_TTS_PROVIDER TTS 供应商,可选 doubao | fishspeech,默认 doubao
+//
+// FunASR(本地免费 ASR,推荐中文):
+// PICOCLAW_VOICE_ASR_WS_URL FunASR WebSocket 地址,默认 wss://127.0.0.1:10095(内置自签名证书,跳过校验)
+// PICOCLAW_VOICE_ASR_MODE 识别模式:2pass(默认)| online | offline
+//
+// Fish Speech(本地免费 TTS,推荐中文,需 GPU):
+// PICOCLAW_VOICE_TTS_API_URL Fish Speech HTTP 地址,默认 http://127.0.0.1:8080
+// PICOCLAW_VOICE_TTS_REFERENCE_ID 参考音色 ID,留空使用服务默认
+// PICOCLAW_VOICE_TTS_SAMPLE_RATE 输出采样率,默认 44100(Fish Speech v2 默认)
+//
+// 共享火山引擎凭证(doubao 供应商 ASR/TTS 使用同一账号时,只需填这两项):
+// PICOCLAW_VOICE_APPID 火山引擎 AppID(ASR/TTS 共用兜底)
+// PICOCLAW_VOICE_TOKEN 火山引擎 Access Token(ASR/TTS 共用兜底)
+//
+// doubao ASR 单独覆盖(可选):
+// PICOCLAW_VOICE_ASR_APPID 覆盖 PICOCLAW_VOICE_APPID
+// PICOCLAW_VOICE_ASR_TOKEN 覆盖 PICOCLAW_VOICE_TOKEN
+// PICOCLAW_VOICE_ASR_CLUSTER 默认 bigmodel_transcribe
+// PICOCLAW_VOICE_ASR_RESOURCE_ID 默认 volc.bigasr.sauc.duration
+//
+// doubao TTS 单独覆盖(可选):
+// PICOCLAW_VOICE_TTS_APPID 覆盖 PICOCLAW_VOICE_APPID
+// PICOCLAW_VOICE_TTS_TOKEN 覆盖 PICOCLAW_VOICE_TOKEN
+// PICOCLAW_VOICE_TTS_CLUSTER 默认 volcano_tts
+// PICOCLAW_VOICE_TTS_VOICE 音色
+package main
+
+import (
+ "bufio"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/caarlos0/env/v11"
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/asr"
+ _ "github.com/sipeed/picoclaw/pkg/asr/doubao"
+ _ "github.com/sipeed/picoclaw/pkg/asr/funasr"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ picoconfig "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/tts"
+ _ "github.com/sipeed/picoclaw/pkg/tts/doubao"
+ _ "github.com/sipeed/picoclaw/pkg/tts/fishspeech"
+)
+
+type config struct {
+ Listen string `env:"PICOCLAW_VOICE_LISTEN" envDefault:":8765"`
+ OwnerID string `env:"PICOCLAW_VOICE_OWNER_ID"`
+
+ // 共享凭证:ASR 和 TTS 可复用同一套火山引擎账号
+ AppID string `env:"PICOCLAW_VOICE_APPID"`
+ Token string `env:"PICOCLAW_VOICE_TOKEN"`
+
+ // ASR 供应商选择 (doubao | funasr)
+ ASRProvider string `env:"PICOCLAW_VOICE_ASR_PROVIDER" envDefault:"doubao"`
+ ASRAppID string `env:"PICOCLAW_VOICE_ASR_APPID"` // doubao:覆盖 PICOCLAW_VOICE_APPID
+ ASRToken string `env:"PICOCLAW_VOICE_ASR_TOKEN"` // doubao:覆盖 PICOCLAW_VOICE_TOKEN
+ ASRCluster string `env:"PICOCLAW_VOICE_ASR_CLUSTER" envDefault:"bigmodel_transcribe"`
+ ASRResourceID string `env:"PICOCLAW_VOICE_ASR_RESOURCE_ID"` // doubao:资源 ID
+ ASRWsURL string `env:"PICOCLAW_VOICE_ASR_WS_URL"` // funasr:WebSocket 地址
+ ASRMode string `env:"PICOCLAW_VOICE_ASR_MODE"` // funasr:2pass | online | offline
+
+ // TTS 供应商选择 (doubao | fishspeech)
+ TTSProvider string `env:"PICOCLAW_VOICE_TTS_PROVIDER" envDefault:"doubao"`
+ TTSAPIURL string `env:"PICOCLAW_VOICE_TTS_API_URL"` // fishspeech:HTTP 服务地址
+ TTSAPIKey string `env:"PICOCLAW_VOICE_TTS_API_KEY"` // fishspeech:Bearer Token
+ TTSReferenceID string `env:"PICOCLAW_VOICE_TTS_REFERENCE_ID"` // fishspeech:参考音色 ID
+ TTSSampleRate int `env:"PICOCLAW_VOICE_TTS_SAMPLE_RATE" envDefault:"0"` // fishspeech:输出采样率,0=provider 默认
+ TTSSeed int `env:"PICOCLAW_VOICE_TTS_SEED" envDefault:"0"` // fishspeech:固定随机种子,0=随机音色
+ TTSAppID string `env:"PICOCLAW_VOICE_TTS_APPID"` // doubao:覆盖 PICOCLAW_VOICE_APPID
+ TTSToken string `env:"PICOCLAW_VOICE_TTS_TOKEN"` // doubao:覆盖 PICOCLAW_VOICE_TOKEN
+ TTSCluster string `env:"PICOCLAW_VOICE_TTS_CLUSTER" envDefault:"volcano_tts"`
+ TTSVoice string `env:"PICOCLAW_VOICE_TTS_VOICE"`
+}
+
+func main() {
+ // 优先从可执行文件所在目录加载 .env,回退到当前工作目录。
+ // 已有同名环境变量时跳过(不覆盖),即 Docker environment: 覆盖 > .env。
+ if exe, err := os.Executable(); err == nil {
+ loadDotEnv(filepath.Join(filepath.Dir(exe), ".env"))
+ } else {
+ loadDotEnv(".env")
+ }
+
+ var cfg config
+ if err := env.Parse(&cfg); err != nil {
+ log.Fatalf("picoclaw-voice: parse config: %v", err)
+ }
+
+ // 加载 picoclaw config 并初始化 AgentLoop (LLM)
+ pcCfgPath := picoclawConfigPath()
+ pcCfg, err := picoconfig.LoadConfig(pcCfgPath)
+ if err != nil {
+ log.Fatalf("picoclaw-voice: load picoclaw config from %s: %v", pcCfgPath, err)
+ }
+ llmProvider, modelID, err := providers.CreateProvider(pcCfg)
+ if err != nil {
+ log.Fatalf("picoclaw-voice: create LLM provider: %v", err)
+ }
+ if modelID != "" {
+ pcCfg.Agents.Defaults.ModelName = modelID
+ }
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(pcCfg, msgBus, llmProvider)
+
+ log.Printf("picoclaw-voice: ASR config: provider=%s ws_url=%s mode=%s", cfg.ASRProvider, cfg.ASRWsURL, cfg.ASRMode)
+ log.Printf("picoclaw-voice: TTS config: provider=%s api_url=%s seed=%d", cfg.TTSProvider, cfg.TTSAPIURL, cfg.TTSSeed)
+
+ asrProvider, err := asr.New(cfg.ASRProvider, map[string]any{
+ // doubao 字段
+ "appid": orStr(cfg.ASRAppID, cfg.AppID),
+ "access_token": orStr(cfg.ASRToken, cfg.Token),
+ "cluster": cfg.ASRCluster,
+ "resource_id": orStr(cfg.ASRResourceID, "volc.bigasr.sauc.duration"),
+ // funasr 字段
+ "ws_url": cfg.ASRWsURL,
+ "mode": cfg.ASRMode,
+ })
+ if err != nil {
+ log.Fatalf("picoclaw-voice: init ASR: %v", err)
+ }
+
+ ttsProvider, err := tts.New(cfg.TTSProvider, map[string]any{
+ // fishspeech 字段
+ "api_url": cfg.TTSAPIURL,
+ "api_key": cfg.TTSAPIKey,
+ "reference_id": cfg.TTSReferenceID,
+ "sample_rate": cfg.TTSSampleRate,
+ "seed": cfg.TTSSeed,
+ // doubao 字段
+ "appid": orStr(cfg.TTSAppID, cfg.AppID),
+ "access_token": orStr(cfg.TTSToken, cfg.Token),
+ "cluster": cfg.TTSCluster,
+ "voice": cfg.TTSVoice,
+ })
+ if err != nil {
+ log.Fatalf("picoclaw-voice: init TTS: %v", err)
+ }
+ upgrader := websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+ }
+
+ reg := newDeviceRegistry()
+
+ http.HandleFunc("/xiaozhi/v1/", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Printf("picoclaw-voice: ws upgrade: %v", err)
+ return
+ }
+ s := newSession(conn, asrProvider, ttsProvider, agentLoop, cfg.OwnerID, reg)
+ s.run()
+ })
+
+ log.Printf("picoclaw-voice: listening on %s", cfg.Listen)
+ if err := http.ListenAndServe(cfg.Listen, nil); err != nil {
+ log.Fatalf("picoclaw-voice: %v", err)
+ }
+}
+
+// picoclawConfigPath 返回 picoclaw config.json 路径。
+// 优先级: $PICOCLAW_CONFIG > $PICOCLAW_HOME/config.json > ~/.picoclaw/config.json
+func picoclawConfigPath() string {
+ if p := os.Getenv("PICOCLAW_CONFIG"); p != "" {
+ return p
+ }
+ if h := os.Getenv("PICOCLAW_HOME"); h != "" {
+ return filepath.Join(h, "config.json")
+ }
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".picoclaw", "config.json")
+}
+
+// orStr 返回 a(若非空),否则返回 b。
+func orStr(a, b string) string {
+ if a != "" {
+ return a
+ }
+ return b
+}
+
+// loadDotEnv 从指定路径加载 .env 文件。
+// 格式:KEY=VALUE,忽略空行和 # 注释行。
+// .env 的值总是生效(覆盖 stale shell export),因此本地开发无需手动 unset 旧变量。
+// Docker 容器内不存在此文件(Dockerfile 未 COPY),故 Docker 环境不受影响。
+func loadDotEnv(path string) {
+ f, err := os.Open(path)
+ if err != nil {
+ return // 文件不存在时静默跳过(Docker 容器内正常触发此分支)
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ idx := strings.IndexByte(line, '=')
+ if idx < 0 {
+ continue
+ }
+ key := strings.TrimSpace(line[:idx])
+ val := strings.TrimSpace(line[idx+1:])
+ if key == "" {
+ continue
+ }
+ os.Setenv(key, val) // 总是覆盖,防止 stale shell export 干扰本地调试
+ }
+}
diff --git a/cmd/picoclaw-voice/protocol.go b/cmd/picoclaw-voice/protocol.go
new file mode 100644
index 000000000..af60405b6
--- /dev/null
+++ b/cmd/picoclaw-voice/protocol.go
@@ -0,0 +1,197 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package main
+
+// protocol.go 定义 picoclaw-voice 与客户端之间的 WebSocket 消息协议。
+//
+// 基础协议:xiaozhi(https://github.com/78/xiaozhi-esp32)
+// 扩展协议:picoclaw 在 xiaozhi 基础上新增若干字段和消息类型,统一标注为 [picoclaw 扩展]。
+//
+// ---- 客户端 → 服务端 ----
+//
+// hello 握手,见 helloMsg
+// listen VAD 控制,见 listenMsg;state = "start" | "end" | "stop"
+// abort 打断当前播放,见 abortMsg
+// ping 心跳(服务端回 pong,本实现暂不处理)
+//
+// ---- 服务端 → 客户端 ----
+//
+// hello 握手响应,见 helloReplyMsg
+// stt 语音识别结果,见 sttMsg
+// tts 语音合成控制,见 ttsMsg;sentence_start/sentence_end 之间是对应句子的二进制 Opus 帧
+//
+// [picoclaw 扩展] 服务端 → 客户端:
+//
+// llm LLM 推理通知,见 llmMsg
+//
+// ---- picoclaw 扩展字段一览 ----
+//
+// 客户端 → 服务端:
+//
+// listen.memory_id string
+// LLM 多轮记忆 key。相同 memory_id 的多次对话共享同一上下文(跨设备、跨会话)。
+// 不传时退化为 connID,即单连接内记忆隔离。
+// 典型用法:同一用户在 App 和硬件设备上使用相同 memory_id,实现跨渠道记忆统一。
+//
+// 服务端 → 客户端:
+//
+// hello.session_id string
+// 连接级 ID,由服务端在每次 WebSocket 握手时生成(UUID)。
+// 设备重连时刷新;与客户端 listen.session_id(turn_id,轮次标识)语义不同。
+// 主要用途:服务端日志关联,客户端无需持久化。
+//
+// llm(新增消息类型)
+// LLM 推理过程通知,xiaozhi 标准协议中无此消息类型,见 llmMsg。
+// 三种形态:
+// {"type":"llm","text":"..."}
+// LLM 断句后的回复文字。比对应的 tts.sentence_start 早约 200-500ms 发出,
+// 可用于在音频播放前在显示屏上呈现打字机效果。
+// {"type":"llm","state":"thinking_start"}
+// 检测到 标记,模型进入思考阶段。
+// {"type":"llm","state":"thinking_end","duration_ms":N}
+// 检测到 标记,思考结束,duration_ms 为本次 thinking 耗时(毫秒)。
+
+import (
+ "encoding/json"
+
+ "github.com/sipeed/picoclaw/pkg/asr"
+ "github.com/sipeed/picoclaw/pkg/tts"
+)
+
+// ---- 客户端 → 服务端 ----
+
+// helloMsg 是客户端握手消息。picoclaw 协议仅使用 device_id 作为设备标识。
+type helloMsg struct {
+ Type string `json:"type"`
+ Version int `json:"version"`
+ Transport string `json:"transport"`
+ DeviceID string `json:"device_id,omitempty"`
+ AudioParams *audioParams `json:"audio_params,omitempty"`
+}
+
+type audioParams struct {
+ Format string `json:"format"`
+ SampleRate int `json:"sample_rate"`
+ Channels int `json:"channels"`
+}
+
+// listenMsg 是客户端语音控制消息。
+// SessionID(session_id)由客户端在每轮问答开始时生成,服务端用作 turn_id,贯穿整轮 ASR→LLM→TTS。
+// MemoryID(memory_id)是 picoclaw 协议扩展字段:指定 LLM 多轮记忆 key,控制跨会话上下文共享。
+type listenMsg struct {
+ Type string `json:"type"`
+ State string `json:"state"` // "start" | "end" | "stop"
+ Mode string `json:"mode,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ MemoryID string `json:"memory_id,omitempty"` // [picoclaw 扩展] LLM 多轮记忆 key
+}
+
+type abortMsg struct {
+ Type string `json:"type"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// ---- 服务端 → 客户端 ----
+
+// helloReplyAudioParams 是服务端 hello 响应中的 audio_params 字段。
+// frame_duration=60 为 xiaozhi 兼容固定值,用于设备端播放节拍控制。
+type helloReplyAudioParams struct {
+ Format string `json:"format"`
+ SampleRate int `json:"sample_rate"`
+ Channels int `json:"channels"`
+ FrameDuration int `json:"frame_duration"`
+}
+
+// helloReplyMsg 是服务端握手响应。
+// [picoclaw 扩展] session_id 承载服务端生成的连接级 ID,在设备重连时刷新;
+// 与客户端 listen.session_id(turn_id)是不同语义的字段。
+//
+// audio_params:上行音频格式(客户端 → 服务端,用于 ASR),由 ASR provider 声明。
+// tts_params:下行音频格式(服务端 → 客户端,用于 TTS 播放),固定为 Opus 16kHz mono。
+type helloReplyMsg struct {
+ Type string `json:"type"`
+ Version int `json:"version"`
+ Transport string `json:"transport"`
+ SessionID string `json:"session_id"`
+ AsrParams helloReplyAudioParams `json:"asr_params"` // 上行:ASR 期望格式
+ TTSParams helloReplyAudioParams `json:"tts_params"` // 下行:TTS 输出格式
+}
+
+// sttMsg 是 ASR 识别结果通知。
+type sttMsg struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ State string `json:"state"` // "recognizing"(中间结果)| "stop"(最终结果)
+}
+
+// ttsMsg 驱动客户端播放状态机。
+type ttsMsg struct {
+ Type string `json:"type"`
+ State string `json:"state"` // "start" | "sentence_start" | "sentence_end" | "stop" | "abort"
+ Text string `json:"text,omitempty"` // 仅 sentence_start 携带
+}
+
+// llmMsg 是 picoclaw 扩展的 LLM 推理通知,xiaozhi 标准协议无此类型。
+// State="thinking_start":检测到 ;State="thinking_end":检测到 ,携带 DurationMs。
+// Text 字段:LLM 断句后的回复文字,早于对应 tts.sentence_start 发出。
+type llmMsg struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ State string `json:"state,omitempty"`
+ DurationMs int64 `json:"duration_ms,omitempty"`
+}
+
+// ---- 构造函数 ----
+
+func newStt(text, state string) []byte {
+ b, _ := json.Marshal(sttMsg{Type: "stt", Text: text, State: state})
+ return b
+}
+
+func newTts(state, text string) []byte {
+ b, _ := json.Marshal(ttsMsg{Type: "tts", State: state, Text: text})
+ return b
+}
+
+func newLlmText(text string) []byte {
+ b, _ := json.Marshal(llmMsg{Type: "llm", Text: text})
+ return b
+}
+
+func newLlmThinkingStart() []byte {
+ b, _ := json.Marshal(llmMsg{Type: "llm", State: "thinking_start"})
+ return b
+}
+
+func newLlmThinkingEnd(ms int64) []byte {
+ b, _ := json.Marshal(llmMsg{Type: "llm", State: "thinking_end", DurationMs: ms})
+ return b
+}
+
+// helloReply 构建服务端 hello 响应。
+// asrFmt:ASR provider 声明的上行格式,客户端必须按此格式发送音频。
+// ttsFmt:TTS provider 声明的下行格式,客户端按此初始化解码器。
+func helloReply(sessID string, asrFmt asr.AudioFormat, ttsFmt tts.AudioFormat) []byte {
+ b, _ := json.Marshal(helloReplyMsg{
+ Type: "hello",
+ Version: 3,
+ Transport: "websocket",
+ SessionID: sessID,
+ AsrParams: helloReplyAudioParams{
+ Format: asrFmt.Codec,
+ SampleRate: asrFmt.SampleRate,
+ Channels: asrFmt.Channels,
+ FrameDuration: 60,
+ },
+ TTSParams: helloReplyAudioParams{
+ Format: ttsFmt.Codec,
+ SampleRate: ttsFmt.SampleRate,
+ Channels: ttsFmt.Channels,
+ FrameDuration: 60,
+ },
+ })
+ return b
+}
diff --git a/docker/docker-compose.asr-tts.yml b/docker/docker-compose.asr-tts.yml
new file mode 100644
index 000000000..a78c427bb
--- /dev/null
+++ b/docker/docker-compose.asr-tts.yml
@@ -0,0 +1,83 @@
+# 本地 AI 推理服务(FunASR + Fish Speech)
+#
+# 用法(在 repo 根目录执行):
+# docker compose -f docker/docker-compose.asr-tts.yml up -d
+# docker compose -f docker/docker-compose.asr-tts.yml logs -f
+# docker compose -f docker/docker-compose.asr-tts.yml down
+#
+# 前置条件:
+# 1. 安装 NVIDIA Container Toolkit(Docker GPU 支持):
+# curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
+# ... (见 README)
+# 2. FunASR 模型首次启动自动从 ModelScope 下载到 ~/models/funasr/(约 500MB)
+# 3. Fish Speech 模型已内置于 v1.5.1 镜像,无需单独下载
+#
+# picoclaw-voice 配置:
+# PICOCLAW_VOICE_ASR_PROVIDER=funasr
+# PICOCLAW_VOICE_ASR_WS_URL=ws://127.0.0.1:10095
+# PICOCLAW_VOICE_TTS_PROVIDER=fishspeech
+# PICOCLAW_VOICE_TTS_API_URL=http://127.0.0.1:8080
+
+services:
+
+ # ─── FunASR:本地中文语音识别(2pass 模式,免费)──────────────────────────
+ # 首次启动自动下载模型(约 1 GB),需要访问 ModelScope(modelscope.cn)
+ funasr:
+ image: funasr/funasr:funasr-runtime-sdk-cpu-0.1.0
+ container_name: funasr
+ restart: unless-stopped
+ ports:
+ - "10095:10095"
+ volumes:
+ # 模型缓存,避免每次重启重新下载
+ - ${HOME}/models/funasr:/workspace/models
+ command: >
+ bash -c "cd /workspace/FunASR/funasr/runtime &&
+ bash run_server.sh
+ --download-model-dir /workspace/models
+ --model-dir damo/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-onnx
+ --vad-dir damo/speech_fsmn_vad_zh-cn-16k-common-onnx
+ --punc-dir damo/punc_ct-transformer_zh-cn-common-vocab272727-onnx
+ --port 10095
+ --certfile 0 --keyfile 0"
+ healthcheck:
+ test: ["CMD-SHELL", "nc -z localhost 10095 || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 120s # 首次启动需下载模型,延迟检查
+
+ # ─── Fish Speech:本地中文 TTS(PCM 输出,模型内置于镜像,需 GPU)──────────
+ # 使用 v1.5.1 版本(含 FireflyGAN decoder,模型已内置无需挂载)
+ fishspeech:
+ image: fishaudio/fish-speech:v1.5.1
+ container_name: fishspeech
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ command:
+ - python
+ - tools/api_server.py
+ - --llama-checkpoint-path
+ - checkpoints/fish-speech-1.5
+ - --decoder-checkpoint-path
+ - checkpoints/fish-speech-1.5/firefly-gan-vq-fsq-8x1024-21hz-generator.pth
+ - --decoder-config-name
+ - firefly_gan_vq
+ - --device
+ - cuda
+ - --listen
+ - 0.0.0.0:8080
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://localhost:8080/ || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
diff --git a/docker/docker-compose.voice.yml b/docker/docker-compose.voice.yml
new file mode 100644
index 000000000..693b077c1
--- /dev/null
+++ b/docker/docker-compose.voice.yml
@@ -0,0 +1,52 @@
+# picoclaw-voice 部署 docker-compose
+#
+# 用法:
+# 首次启动:docker compose -f docker/docker-compose.voice.yml up -d --build
+# 重启: docker compose -f docker/docker-compose.voice.yml restart
+# 查看日志:docker compose -f docker/docker-compose.voice.yml logs -f
+# 停止: docker compose -f docker/docker-compose.voice.yml down
+#
+# 注意:picoclaw-voice 端口由 .env 中的 PICOCLAW_VOICE_LISTEN 决定(默认 :8765)。
+# 若修改 PICOCLAW_VOICE_LISTEN 端口,需同步修改下方 ports 映射。
+
+services:
+ # picoclaw gateway + Web UI
+ # Web UI:http://localhost:18800
+ picoclaw:
+ image: sipeed/picoclaw:launcher
+ container_name: picoclaw
+ restart: unless-stopped
+ environment:
+ # 允许容器外部(Web UI)访问 gateway
+ - PICOCLAW_GATEWAY_HOST=0.0.0.0
+ ports:
+ # Web UI,仅本机可访问
+ - "127.0.0.1:18800:18800"
+ volumes:
+ # 与 picoclaw-voice 共享同一份配置、workspace、记忆
+ - /home/kkroid/.picoclaw:/root/.picoclaw
+
+ # picoclaw-voice:xiaozhi 协议语音网关(doubao ASR/TTS)
+ # picoclaw-voice 客户端 连接:ws://:18765
+ picoclaw-voice:
+ build:
+ # context 必须是 repo 根,因为 go.mod 有 replace => ../../
+ context: ..
+ dockerfile: cmd/picoclaw-voice/Dockerfile
+ image: picoclaw-voice:local
+ container_name: picoclaw-voice
+ restart: unless-stopped
+ env_file: ../cmd/picoclaw-voice/.env
+ environment:
+ # 容器内通过 host-gateway 访问宿主机的 funasr/fishspeech
+ - PICOCLAW_VOICE_ASR_WS_URL=wss://host.docker.internal:10095
+ - PICOCLAW_VOICE_TTS_API_URL=http://host.docker.internal:8080
+ ports:
+ # picoclaw-voice 客户端(UE5,Windows)经 WSL 局域网连接此端口
+ - "18765:18765"
+ extra_hosts:
+ # Linux 下使 host.docker.internal 解析到宿主机(访问 funasr/fishspeech)
+ - "host.docker.internal:host-gateway"
+ volumes:
+ # 读取 picoclaw 配置:LLM API key、记忆、MCP 工具
+ - /home/kkroid/.picoclaw:/root/.picoclaw
diff --git a/docs/channels/xiaozhi/README.zh.md b/docs/channels/xiaozhi/README.zh.md
new file mode 100644
index 000000000..763f96c72
--- /dev/null
+++ b/docs/channels/xiaozhi/README.zh.md
@@ -0,0 +1,248 @@
+# xiaozhi 频道(picoclaw-voice)
+
+`picoclaw-voice` 是实现 xiaozhi-esp32 WebSocket 协议的独立语音网关进程。它将运行 xiaozhi 固件的 ESP32 设备或任意兼容客户端接入 PicoClaw AI 引擎,完成 **Opus 音频 → ASR → LLM → TTS → Opus 音频** 的全双工对话链路。
+
+## 架构概览
+
+```
+xiaozhi 客户端 picoclaw-voice PicoClaw
+(ESP32 / UE5 / ...) WebSocket :8765 config.json
+ │ │ │
+ │ WebSocket (Opus) │ │
+ │ ─────────────────────> │ │
+ │ │ 火山引擎 ASR (doubao) │
+ │ │ ─────────────────────> │
+ │ │ AgentLoop (LLM) │
+ │ │ ─────────────────────> │
+ │ │ 火山引擎 TTS (doubao) │
+ │ WebSocket (Opus) │ ─────────────────────> │
+ │ <───────────────────── │ │
+```
+
+## 连接
+
+WebSocket 端点:`ws://:8765/xiaozhi/v1/`
+
+连接建立后,**客户端必须首先发送 `hello` 消息**,完成握手后方可收发音频。
+
+## 消息格式
+
+所有文本消息均为 JSON,二进制消息为原始 Opus 帧。
+
+### 客户端 → 服务端
+
+#### hello — 握手
+
+```json
+{
+ "type": "hello",
+ "version": 3,
+ "transport": "websocket",
+ "device_id": "aa:bb:cc:dd:ee:ff",
+ "audio_params": {
+ "format": "opus",
+ "sample_rate": 16000,
+ "channels": 1,
+ "frame_duration": 60
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `version` | int | 是 | 协议版本,当前为 `3` |
+| `transport` | string | 是 | 传输方式,固定 `"websocket"` |
+| `device_id` | string | 否 | 设备唯一标识(MAC 地址或自定义 ID);留空时服务端自动分配 |
+| `audio_params.format` | string | 是 | 音频格式,固定 `"opus"` |
+| `audio_params.sample_rate` | int | 是 | 采样率,固定 `16000` |
+| `audio_params.channels` | int | 是 | 声道数,固定 `1` |
+| `audio_params.frame_duration` | int | 是 | 帧时长(ms),固定 `60` |
+
+---
+
+#### listen — VAD 控制
+
+```json
+{
+ "type": "listen",
+ "state": "start",
+ "session_id": "uuid-v4",
+ "memory_id": "user-key"
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `state` | string | 是 | `"start"` 开始录音 / `"end"` 停止录音(VAD 完成)/ `"stop"` 取消当前轮次 |
+| `session_id` | string | 否 | 本轮对话的唯一 ID(turn_id);`start` 时建议由客户端生成 UUID,服务端兜底自动生成 |
+| `memory_id` | string | 否 | 指定 LLM 记忆 key;留空时依次回退:`PICOCLAW_VOICE_OWNER_ID` → `device_id` |
+
+状态转换:
+
+```
+(空闲) ── start ──> (录音中) ── end ──> (推理中) ── (完成) ──> (空闲)
+ └── stop ──> (空闲,丢弃当前音频)
+```
+
+---
+
+#### abort — 打断
+
+```json
+{
+ "type": "abort",
+ "reason": "wake_word_detected"
+}
+```
+
+立即中止当前 TTS 播放和推理,服务端发送 `tts.state=abort` 并回到空闲状态。
+
+---
+
+#### 二进制帧 — 上行音频
+
+`listen.start` 之后持续发送的 Opus 压缩帧,每帧 60 ms(960 samples @ 16 kHz)。
+
+---
+
+### 服务端 → 客户端
+
+#### hello — 握手响应
+
+```json
+{
+ "type": "hello",
+ "version": 3,
+ "transport": "websocket",
+ "session_id": "uuid-v4",
+ "audio_params": {
+ "format": "opus",
+ "sample_rate": 16000,
+ "channels": 1,
+ "frame_duration": 60
+ }
+}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `session_id` | 本次连接的会话 ID |
+
+---
+
+#### stt — ASR 识别结果
+
+```json
+{ "type": "stt", "text": "你好世界", "state": "recognizing" }
+{ "type": "stt", "text": "你好世界。", "state": "stop" }
+```
+
+| `state` 值 | 说明 |
+|------------|------|
+| `"recognizing"` | 流式中间结果(可能更新) |
+| `"stop"` | 最终识别结果 |
+
+---
+
+#### llm — LLM 推理内容
+
+```json
+{ "type": "llm", "text": "你好!有什么可以帮你的?", "emotion": "neutral" }
+```
+
+| 字段 | 说明 |
+|------|------|
+| `text` | 本句 LLM 输出文本(断句后逐句发送) |
+| `emotion` | 当前情绪标签,固定 `"neutral"`(预留扩展) |
+
+---
+
+#### tts — TTS 状态控制
+
+```json
+{ "type": "tts", "state": "start" }
+{ "type": "tts", "state": "sentence_start", "text": "你好!有什么可以帮你的?" }
+{ "type": "tts", "state": "sentence_end" }
+{ "type": "tts", "state": "stop" }
+```
+
+| `state` 值 | 说明 |
+|------------|------|
+| `"start"` | 开始整轮 TTS 播放 |
+| `"sentence_start"` | 本句开始,附带文本 `text` 字段 |
+| `"sentence_end"` | 本句结束 |
+| `"stop"` | 整轮 TTS 播放结束 |
+| `"abort"` | TTS 被打断(客户端发 `abort` 或会话异常) |
+
+---
+
+#### 二进制帧 — 下行音频
+
+TTS 合成后的 Opus 帧,编码参数与握手一致(16 kHz、单声道、60 ms)。
+
+---
+
+## 完整会话时序
+
+```
+Client Server
+ │ │
+ │ WS connect │
+ │ ─────────────────────────> │
+ │ hello (device_id, ...) │
+ │ ─────────────────────────> │
+ │ │ hello (session_id)
+ │ <───────────────────────── │
+ │ │
+ │ listen (state=start) │
+ │ ─────────────────────────> │
+ │ [binary Opus frames] │
+ │ ─────────────────────────> │
+ │ listen (state=end) │
+ │ ─────────────────────────> │
+ │ │ stt (recognizing)
+ │ <───────────────────────── │
+ │ │ stt (stop, final text)
+ │ <───────────────────────── │
+ │ │ llm (text=...)
+ │ <───────────────────────── │
+ │ │ tts (start)
+ │ <───────────────────────── │
+ │ │ tts (sentence_start)
+ │ <───────────────────────── │
+ │ │ [binary Opus frames]
+ │ <───────────────────────── │
+ │ │ tts (sentence_end)
+ │ <───────────────────────── │
+ │ │ tts (stop)
+ │ <───────────────────────── │
+```
+
+## 音频规格
+
+| 参数 | 值 |
+|------|----|
+| 编码 | Opus (libopus) |
+| 采样率 | 16000 Hz |
+| 声道 | 1(单声道) |
+| 帧时长 | 60 ms(960 samples/帧) |
+| 最大帧长 | 4000 bytes |
+| 应用模式 | VoIP (`OPUS_APPLICATION_VOIP`) |
+
+## 会话与记忆管理
+
+**device_id 注册**:同一 `device_id` 新连接到来时,旧会话立即被关闭(last-write-wins),确保不存在幽灵会话。
+
+**记忆 key 优先级**(高→低):
+1. `PICOCLAW_VOICE_OWNER_ID` 环境变量(强制全局共享,适合单用户部署)
+2. 客户端 `listen.memory_id` 字段
+3. 客户端 `hello.device_id`
+4. 服务端自动生成的连接 ID
+
+**turn_id**:每轮对话的唯一标识,由客户端在 `listen.start` 时通过 `session_id` 字段传入;客户端未提供时服务端自动生成。
+
+## 与 xiaozhi-esp32-server 的关系
+
+本模块实现了与 [xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) 兼容的 v3 协议子集,替换其 LLM/ASR/TTS 后端为 PicoClaw 引擎与火山引擎服务。
+
+未实现的 xiaozhi 协议扩展:OTA 更新、配网相关消息。
diff --git a/go.mod b/go.mod
index 130db73ff..db235e8df 100644
--- a/go.mod
+++ b/go.mod
@@ -49,6 +49,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
+ github.com/pion/opus v0.0.0-20260219180131-abe26becac00 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
diff --git a/go.sum b/go.sum
index a4d8ed3d0..83f3ae40f 100644
--- a/go.sum
+++ b/go.sum
@@ -156,6 +156,8 @@ github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixi
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pion/opus v0.0.0-20260219180131-abe26becac00 h1:+PXsZ5OSLoyzPdzKvr8X/C4WtIWk7GbAAIKZOrFV744=
+github.com/pion/opus v0.0.0-20260219180131-abe26becac00/go.mod h1:t5Xog2n682JnawoykACE6nKVmupFvmJvkpM7x6bTv6g=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -352,6 +354,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302 h1:xeVptzkP8BuJhoIjNizd2bRHfq9KB9HfOLZu90T04XM=
+gopkg.in/hraban/opus.v2 v2.0.0-20230925203106-0188a62cb302/go.mod h1:/L5E7a21VWl8DeuCPKxQBdVG5cy+L0MRZ08B1wnqt7g=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/pkg/agent/stream.go b/pkg/agent/stream.go
new file mode 100644
index 000000000..280dc64c2
--- /dev/null
+++ b/pkg/agent/stream.go
@@ -0,0 +1,116 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package agent
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// RunStreamAgentLoop runs a streaming LLM agent loop for voice input.
+// Text tokens are delivered via onToken as they are generated by the LLM.
+// Tool calls are executed transparently between iterations; the loop
+// re-prompts with results and continues until no more tool calls are issued
+// or MaxIterations is reached.
+// The full exchange is persisted to session memory on completion.
+func (al *AgentLoop) RunStreamAgentLoop(
+ ctx context.Context,
+ sessionKey, userText, channel, chatID string,
+ onToken func(string),
+) error {
+ agentInst := al.registry.GetDefaultAgent()
+ if agentInst == nil {
+ return fmt.Errorf("no default agent available")
+ }
+
+ sp, ok := agentInst.Provider.(providers.StreamingProvider)
+ if !ok {
+ return fmt.Errorf("provider %T does not implement StreamingProvider", agentInst.Provider)
+ }
+
+ start := time.Now()
+
+ history := agentInst.Sessions.GetHistory(sessionKey)
+ summary := agentInst.Sessions.GetSummary(sessionKey)
+ messages := agentInst.ContextBuilder.BuildMessages(
+ history, summary, userText, nil, channel, chatID,
+ )
+
+ agentInst.Sessions.AddMessage(sessionKey, "user", userText)
+
+ llmOpts := map[string]any{
+ "max_tokens": agentInst.MaxTokens,
+ "temperature": agentInst.Temperature,
+ "prompt_cache_key": agentInst.ID,
+ }
+
+ var finalContent string
+ for iteration := 0; iteration < agentInst.MaxIterations; iteration++ {
+ toolDefs := agentInst.Tools.ToProviderDefs()
+
+ // Only stream tokens on the first (user-facing) iteration.
+ // Subsequent iterations are tool-follow-up calls; no need to stream them.
+ var tokenCb func(string)
+ if iteration == 0 {
+ tokenCb = onToken
+ }
+
+ resp, err := sp.ChatStream(ctx, messages, toolDefs, agentInst.Model, llmOpts, tokenCb)
+ if err != nil {
+ return fmt.Errorf("stream error (iteration %d): %w", iteration+1, err)
+ }
+ finalContent = resp.Content
+
+ if len(resp.ToolCalls) == 0 {
+ break
+ }
+
+ // Normalize tool calls so Name/Arguments map are always populated.
+ normalizedCalls := make([]providers.ToolCall, 0, len(resp.ToolCalls))
+ for _, tc := range resp.ToolCalls {
+ normalizedCalls = append(normalizedCalls, providers.NormalizeToolCall(tc))
+ }
+
+ // Append assistant message with tool calls.
+ assistantMsg := providers.Message{
+ Role: "assistant",
+ Content: resp.Content,
+ }
+ for _, tc := range normalizedCalls {
+ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
+ }
+ messages = append(messages, assistantMsg)
+
+ // Execute each tool and add results to context.
+ for _, tc := range normalizedCalls {
+ result := agentInst.Tools.Execute(ctx, tc.Name, tc.Arguments)
+ messages = agentInst.ContextBuilder.AddToolResult(
+ messages, tc.ID, tc.Name, result.ForLLM,
+ )
+ logger.DebugCF("voice-stream", "Tool executed",
+ map[string]any{"tool": tc.Name, "result_len": len(result.ForLLM)})
+ }
+ }
+
+ agentInst.Sessions.AddMessage(sessionKey, "assistant", finalContent)
+ if err := agentInst.Sessions.Save(sessionKey); err != nil {
+ logger.WarnCF("voice-stream", "Failed to save session",
+ map[string]any{"session_key": sessionKey, "error": err.Error()})
+ }
+
+ logger.InfoCF("voice-stream", "Stream completed",
+ map[string]any{
+ "session_key": sessionKey,
+ "latency_ms": time.Since(start).Milliseconds(),
+ "content_len": len(finalContent),
+ })
+
+ return nil
+}
diff --git a/pkg/asr/doubao/provider.go b/pkg/asr/doubao/provider.go
new file mode 100644
index 000000000..26540dbbe
--- /dev/null
+++ b/pkg/asr/doubao/provider.go
@@ -0,0 +1,500 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// Package doubao implements the Doubao (火山引擎) streaming ASR provider.
+// Protocol: binary WebSocket with a 4-byte header + gzip-compressed payloads.
+// Docs: https://www.volcengine.com/docs/6561/80818
+package doubao
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+ "github.com/sipeed/picoclaw/pkg/asr"
+)
+
+func init() {
+ asr.Register("doubao", func(cfg map[string]any) (asr.Provider, error) {
+ return newProvider(cfg)
+ })
+}
+
+const defaultASRURL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
+
+// 时长包资源 ID 以控制台实际购买的为准:
+// 模型1.0: volc.bigasr.sauc.duration 模型2.0(Seed): volc.seedasr.sauc.duration
+const defaultResourceID = "volc.bigasr.sauc.duration"
+
+// header byte layout (4 bytes):
+//
+// [0]: (version=0x01 << 4) | header_size=0x01
+// [1]: (message_type << 4) | message_type_specific_flags
+// [2]: (serial_method << 4) | compression_type
+// [3]: reserved=0x00
+const (
+ msgTypeFullClientRequest = 0x01 // JSON init frame
+ msgTypeAudioOnly = 0x02 // audio frame
+ msgTypeServerError = 0x0F
+
+ flagNormal = 0x00
+ flagLastFrame = 0x02
+
+ serialJSON = 0x01
+ compressGZP = 0x01
+)
+
+type provider struct {
+ appID string
+ token string
+ cluster string
+ resourceID string
+ wsURL string
+ dialer *websocket.Dialer
+}
+
+func newProvider(cfg map[string]any) (*provider, error) {
+ appID, _ := cfg["appid"].(string)
+ token, _ := cfg["access_token"].(string)
+ cluster, _ := cfg["cluster"].(string)
+ rid, _ := cfg["resource_id"].(string)
+ wsURL, _ := cfg["ws_url"].(string)
+
+ if rid == "" {
+ rid = defaultResourceID
+ }
+ if wsURL == "" {
+ wsURL = defaultASRURL
+ }
+ // 快捷API接入只需要 token(API Key)和 cluster;传统模式还需要 appid。
+ if token == "" || cluster == "" {
+ return nil, fmt.Errorf("doubao asr: access_token and cluster required")
+ }
+
+ // 不走代理直连火山引擎 ASR,避免本地 http_proxy 拦截 WebSocket 连接。
+ dialer := &websocket.Dialer{
+ NetDialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ HandshakeTimeout: 10 * time.Second,
+ }
+
+ return &provider{appID: appID, token: token, cluster: cluster, resourceID: rid, wsURL: wsURL, dialer: dialer}, nil
+}
+
+func (p *provider) Name() string { return "doubao" }
+
+// AudioFormat 声明 doubao ASR 期望收到原始 PCM 字节(网关透传,无需 Opus 解码)。
+func (p *provider) AudioFormat() asr.AudioFormat {
+ return asr.AudioFormat{Codec: "pcm", SampleRate: 16000, Channels: 1}
+}
+
+// Transcribe sends PCM frames to Doubao streaming ASR and returns the final text.
+func (p *provider) Transcribe(ctx context.Context, frames [][]byte) (string, error) {
+ var final string
+ err := p.transcribeInternal(ctx, asr.MergeFrames(frames), func(text string, isDef bool) {
+ if isDef {
+ final = text
+ }
+ })
+ return final, err
+}
+
+// TranscribeStream sends PCM frames to Doubao streaming ASR and calls callback for each
+// incremental result. callback is invoked only when recognized text changes, and
+// final=true when recognition is complete.
+func (p *provider) TranscribeStream(ctx context.Context, frames [][]byte, callback asr.ResultCallback) error {
+ return p.transcribeInternal(ctx, asr.MergeFrames(frames), callback)
+}
+
+// connect 建立到豆包 ASR 服务的 WebSocket 连接并完成握手。
+// 返回已握手的 conn,调用方负责关闭。
+// ctx 取消时会异步关闭连接,使阻塞的 ReadMessage 立即返回。
+func (p *provider) connect(ctx context.Context) (*websocket.Conn, error) {
+ var headers http.Header
+ if p.appID == "" {
+ headers = http.Header{
+ "Authorization": {"Bearer " + p.token},
+ "X-Api-Resource-Id": {p.resourceID},
+ "X-Api-Connect-Id": {uuid.New().String()},
+ }
+ } else {
+ headers = http.Header{
+ "X-Api-App-Key": {p.appID},
+ "X-Api-Access-Key": {p.token},
+ "X-Api-Resource-Id": {p.resourceID},
+ "X-Api-Connect-Id": {uuid.New().String()},
+ }
+ }
+
+ conn, resp, err := p.dialer.DialContext(ctx, p.wsURL, headers)
+ if err != nil {
+ if resp != nil {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ resp.Body.Close()
+ return nil, fmt.Errorf("doubao asr: dial: %w (HTTP %d: %s)", err, resp.StatusCode, bytes.TrimSpace(body))
+ }
+ return nil, fmt.Errorf("doubao asr: dial: %w", err)
+ }
+
+ // ctx 取消时关闭连接,使 ReadMessage 立即解除阻塞。
+ go func() {
+ <-ctx.Done()
+ conn.Close()
+ }()
+
+ // 发送初始化帧(协议握手)
+ // Model 2.0(seedasr):鉴权纯靠 HTTP Header,body 里不需要 app 节。
+ initReq := map[string]any{
+ "user": map[string]any{"uid": "picoclaw"},
+ "request": map[string]any{
+ "model_name": "bigmodel",
+ "show_utterances": true,
+ "result_type": "stream",
+ "end_window_size": 200,
+ },
+ "audio": map[string]any{
+ "format": "pcm",
+ "codec": "pcm",
+ "rate": 16000,
+ "bits": 16,
+ "channel": 1,
+ "sample_rate": 16000,
+ },
+ }
+ frame, err := buildJSONFrame(msgTypeFullClientRequest, flagNormal, initReq)
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("doubao asr: build init frame: %w", err)
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("doubao asr: send init: %w", err)
+ }
+ _, initResp, err := conn.ReadMessage()
+ if err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("doubao asr: read init response: %w", err)
+ }
+ if err := checkErrorResponse(initResp); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ return conn, nil
+}
+
+// transcribeInternal is the core implementation shared by Transcribe and TranscribeStream.
+// pcmBytes: 16kHz 16-bit mono PCM, little-endian, raw bytes(由 MergeFrames 合并后传入)。
+func (p *provider) transcribeInternal(ctx context.Context, pcmBytes []byte, callback asr.ResultCallback) error {
+ conn, err := p.connect(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ // Send PCM in 100ms chunks(3200 bytes = 1600 samples × 2 bytes),last chunk has flagLastFrame
+ const chunkBytes = 3200
+ for i := 0; i < len(pcmBytes); {
+ end := i + chunkBytes
+ if end > len(pcmBytes) {
+ end = len(pcmBytes)
+ }
+ isLast := end >= len(pcmBytes)
+ flags := byte(flagNormal)
+ if isLast {
+ flags = flagLastFrame
+ }
+ audioFrame, err := buildAudioFrame(flags, pcmBytes[i:end])
+ if err != nil {
+ return fmt.Errorf("doubao asr: build audio frame: %w", err)
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, audioFrame); err != nil {
+ return fmt.Errorf("doubao asr: send audio: %w", err)
+ }
+ i = end
+
+ // Check for context cancellation mid-stream
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ }
+
+ // Read responses until we get a definite utterance or connection closes.
+ var lastText string
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ break // server closed normally
+ }
+ text, definite, done := parseASRResult(msg)
+ // 只在文本有变化时回调,消除重复片段
+ if text != "" && text != lastText {
+ lastText = text
+ label := "partial"
+ if definite {
+ label = "final"
+ }
+ log.Printf("doubao asr: %s text=%q", label, text)
+ if callback != nil {
+ callback(text, definite)
+ }
+ }
+ if done {
+ break
+ }
+ }
+
+ if lastText == "" {
+ log.Printf("doubao asr: no final text (silent or unrecognized)")
+ }
+ return nil
+}
+
+// asrResult carries the final ASR recognition result or an error.
+type asrResult struct {
+ text string
+ err error
+}
+
+// streamingSession is an active live doubao ASR session.
+type streamingSession struct {
+ conn *websocket.Conn
+ sendMu sync.Mutex
+ resultCh chan asrResult // exactly one value written by the read goroutine
+ closedCh chan struct{}
+ closeOnce sync.Once
+}
+
+// OpenSession establishes a new real-time ASR session.
+// The passed context controls the session lifetime: cancellation closes the connection.
+func (p *provider) OpenSession(ctx context.Context, callback asr.ResultCallback) (asr.StreamingSession, error) {
+ conn, err := p.connect(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ sess := &streamingSession{
+ conn: conn,
+ resultCh: make(chan asrResult, 1),
+ closedCh: make(chan struct{}),
+ }
+
+ // 读取协程:持续接收 ASR 结果,将最终结果写入 resultCh
+ go func() {
+ var lastText string
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ sess.resultCh <- asrResult{err: err}
+ return
+ }
+ text, definite, done := parseASRResult(msg)
+ if text != "" && text != lastText {
+ lastText = text
+ label := "partial"
+ if definite {
+ label = "final"
+ }
+ log.Printf("doubao asr: %s text=%q", label, text)
+ if callback != nil {
+ callback(text, definite)
+ }
+ }
+ if done {
+ if lastText == "" {
+ log.Printf("doubao asr: no final text (silent or unrecognized)")
+ }
+ sess.resultCh <- asrResult{text: lastText}
+ return
+ }
+ }
+ }()
+
+ return sess, nil
+}
+
+// SendAudio pushes a raw PCM frame to the live ASR session.
+// frame: 16kHz 16-bit mono PCM bytes(与 AudioFormat 声明一致)。
+func (ss *streamingSession) SendAudio(frame []byte, isLast bool) error {
+ select {
+ case <-ss.closedCh:
+ return asr.ErrSessionClosed
+ default:
+ }
+ flags := byte(flagNormal)
+ if isLast {
+ flags = flagLastFrame
+ }
+ audioFrame, err := buildAudioFrame(flags, frame)
+ if err != nil {
+ return err
+ }
+ ss.sendMu.Lock()
+ defer ss.sendMu.Unlock()
+ return ss.conn.WriteMessage(websocket.BinaryMessage, audioFrame)
+}
+
+// Wait blocks until the final ASR result is available, ctx is cancelled,
+// or the session is closed.
+func (ss *streamingSession) Wait(ctx context.Context) (string, error) {
+ select {
+ case r := <-ss.resultCh:
+ return r.text, r.err
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case <-ss.closedCh:
+ return "", asr.ErrSessionClosed
+ }
+}
+
+// Close aborts the session and releases all resources.
+func (ss *streamingSession) Close() {
+ ss.closeOnce.Do(func() {
+ close(ss.closedCh)
+ ss.conn.Close()
+ })
+}
+
+// buildJSONFrame wraps a JSON payload in the doubao binary frame format.
+func buildJSONFrame(msgType, flags byte, payload any) ([]byte, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ compressed, err := gzipBytes(data)
+ if err != nil {
+ return nil, err
+ }
+ return buildFrame(msgType, flags, serialJSON, compressGZP, compressed), nil
+}
+
+// buildAudioFrame wraps raw PCM bytes in the doubao binary frame format.
+func buildAudioFrame(flags byte, pcmBytes []byte) ([]byte, error) {
+ compressed, err := gzipBytes(pcmBytes)
+ if err != nil {
+ return nil, err
+ }
+ return buildFrame(msgTypeAudioOnly, flags, serialJSON, compressGZP, compressed), nil
+}
+
+func buildFrame(msgType, flags, serial, compress byte, payload []byte) []byte {
+ hdr := [4]byte{
+ (0x01 << 4) | 0x01, // version=1, header_size=1
+ (msgType << 4) | flags,
+ (serial << 4) | compress,
+ 0x00,
+ }
+ frame := make([]byte, 0, 8+len(payload))
+ frame = append(frame, hdr[:]...)
+ frame = binary.BigEndian.AppendUint32(frame, uint32(len(payload)))
+ frame = append(frame, payload...)
+ return frame
+}
+
+func checkErrorResponse(data []byte) error {
+ if len(data) < 4 {
+ return fmt.Errorf("doubao asr: response too short (%d bytes)", len(data))
+ }
+ if (data[1] >> 4) == msgTypeServerError {
+ if len(data) >= 8 {
+ code := binary.BigEndian.Uint32(data[4:8])
+ return fmt.Errorf("doubao asr: server error code=%d", code)
+ }
+ return fmt.Errorf("doubao asr: server error")
+ }
+ return nil
+}
+
+type asrPayload struct {
+ Code int `json:"code"`
+ Result struct {
+ Utterances []struct {
+ Text string `json:"text"`
+ Definite bool `json:"definite"`
+ } `json:"utterances"`
+ } `json:"result"`
+}
+
+// parseASRResult parses a server response frame, handling optional gzip compression.
+// Returns (text, definite, done).
+// - definite=false → 中间结果,text 可能非空(继续等待后续帧)
+// - definite=true → 最终结果,text 非空,done=true
+func parseASRResult(data []byte) (text string, definite bool, done bool) {
+ if len(data) < 12 {
+ return "", false, false
+ }
+ if (data[1] >> 4) == msgTypeServerError {
+ return "", false, true
+ }
+ // byte 2 lower nibble = compression type: 0x01 = gzip
+ payload := data[12:]
+ if data[2]&0x0F == compressGZP {
+ uncompressed, err := gunzipBytes(payload)
+ if err != nil {
+ log.Printf("doubao asr: decompress response: %v", err)
+ return "", false, false
+ }
+ payload = uncompressed
+ }
+ var p asrPayload
+ if err := json.Unmarshal(payload, &p); err != nil {
+ log.Printf("doubao asr: parse response JSON: %v", err)
+ return "", false, false
+ }
+ if p.Code == 1013 { // no speech detected
+ return "", false, true
+ }
+ if p.Code != 0 && p.Code != 1000 {
+ log.Printf("doubao asr: server code=%d", p.Code)
+ }
+ for _, u := range p.Result.Utterances {
+ if u.Text != "" {
+ if u.Definite {
+ return u.Text, true, true
+ }
+ // 返回中间结果,不打 log(由调用方在文本变化时记录)
+ return u.Text, false, false
+ }
+ }
+ return "", false, false
+}
+
+func gunzipBytes(data []byte) ([]byte, error) {
+ r, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ return io.ReadAll(r)
+}
+
+func gzipBytes(data []byte) ([]byte, error) {
+ var buf bytes.Buffer
+ w := gzip.NewWriter(&buf)
+ if _, err := w.Write(data); err != nil {
+ return nil, err
+ }
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/pkg/asr/doubao/provider_test.go b/pkg/asr/doubao/provider_test.go
new file mode 100644
index 000000000..69bc2ef9f
--- /dev/null
+++ b/pkg/asr/doubao/provider_test.go
@@ -0,0 +1,218 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package doubao
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/binary"
+ "encoding/json"
+ "io"
+ "testing"
+)
+
+// ---- buildFrame ----
+
+func TestBuildFrame_HeaderLayout(t *testing.T) {
+ frame := buildFrame(msgTypeFullClientRequest, flagNormal, serialJSON, compressGZP, []byte("payload"))
+
+ if len(frame) < 8 {
+ t.Fatalf("frame too short: %d bytes", len(frame))
+ }
+ // byte[0]: (version=0x01 << 4) | header_size=0x01 = 0x11
+ if frame[0] != 0x11 {
+ t.Errorf("byte[0] = 0x%02x, want 0x11", frame[0])
+ }
+ // byte[1]: (msgType << 4) | flags
+ wantByte1 := byte((msgTypeFullClientRequest << 4) | flagNormal)
+ if frame[1] != wantByte1 {
+ t.Errorf("byte[1] = 0x%02x, want 0x%02x", frame[1], wantByte1)
+ }
+ // byte[2]: (serial << 4) | compress
+ wantByte2 := byte((serialJSON << 4) | compressGZP)
+ if frame[2] != wantByte2 {
+ t.Errorf("byte[2] = 0x%02x, want 0x%02x", frame[2], wantByte2)
+ }
+ // byte[3]: reserved = 0x00
+ if frame[3] != 0x00 {
+ t.Errorf("byte[3] = 0x%02x, want 0x00", frame[3])
+ }
+}
+
+func TestBuildFrame_PayloadLength(t *testing.T) {
+ payload := []byte("hello world")
+ frame := buildFrame(msgTypeAudioOnly, flagLastFrame, serialJSON, compressGZP, payload)
+
+ // bytes[4:8] = big-endian uint32 of len(payload)
+ gotLen := binary.BigEndian.Uint32(frame[4:8])
+ if gotLen != uint32(len(payload)) {
+ t.Errorf("payload length = %d, want %d", gotLen, len(payload))
+ }
+ if !bytes.Equal(frame[8:], payload) {
+ t.Errorf("payload bytes mismatch")
+ }
+}
+
+func TestBuildFrame_AudioFlags(t *testing.T) {
+ frame := buildFrame(msgTypeAudioOnly, flagLastFrame, serialJSON, compressGZP, []byte{})
+ // byte[1] should carry flagLastFrame in lower nibble
+ if frame[1]&0x0F != flagLastFrame {
+ t.Errorf("flags nibble = 0x%x, want 0x%x", frame[1]&0x0F, flagLastFrame)
+ }
+}
+
+// ---- buildJSONFrame ----
+
+func TestBuildJSONFrame_GzipPayload(t *testing.T) {
+ payload := map[string]any{"key": "value"}
+ frame, err := buildJSONFrame(msgTypeFullClientRequest, flagNormal, payload)
+ if err != nil {
+ t.Fatalf("buildJSONFrame: %v", err)
+ }
+ if len(frame) < 8 {
+ t.Fatalf("frame too short: %d", len(frame))
+ }
+ // bytes[4:8] = payload length
+ compressedLen := binary.BigEndian.Uint32(frame[4:8])
+ if int(compressedLen) != len(frame)-8 {
+ t.Errorf("compressed length field %d != actual %d", compressedLen, len(frame)-8)
+ }
+ // Verify gzip decompresses to valid JSON containing "key"
+ r, err := gzip.NewReader(bytes.NewReader(frame[8:]))
+ if err != nil {
+ t.Fatalf("gzip reader: %v", err)
+ }
+ raw, err := io.ReadAll(r)
+ if err != nil {
+ t.Fatalf("gzip read: %v", err)
+ }
+ var out map[string]any
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("json unmarshal: %v", err)
+ }
+ if out["key"] != "value" {
+ t.Errorf("got key=%v, want 'value'", out["key"])
+ }
+}
+
+// ---- buildAudioFrame ----
+
+func TestBuildAudioFrame_NormalFlag(t *testing.T) {
+ pcm := make([]byte, 3200) // 100ms of silence
+ frame, err := buildAudioFrame(flagNormal, pcm)
+ if err != nil {
+ t.Fatalf("buildAudioFrame: %v", err)
+ }
+ if (frame[1] >> 4) != msgTypeAudioOnly {
+ t.Errorf("msgType = 0x%x, want 0x%x", frame[1]>>4, msgTypeAudioOnly)
+ }
+ if frame[1]&0x0F != flagNormal {
+ t.Errorf("flags = 0x%x, want 0x%x", frame[1]&0x0F, flagNormal)
+ }
+}
+
+func TestBuildAudioFrame_LastFlag(t *testing.T) {
+ frame, err := buildAudioFrame(flagLastFrame, []byte{})
+ if err != nil {
+ t.Fatalf("buildAudioFrame: %v", err)
+ }
+ if frame[1]&0x0F != flagLastFrame {
+ t.Errorf("flags = 0x%x, want 0x%x", frame[1]&0x0F, flagLastFrame)
+ }
+}
+
+// ---- parseASRResult ----
+
+func buildMockResponse(code int, text string, definite bool) []byte {
+ payload, _ := json.Marshal(map[string]any{
+ "code": code,
+ "result": map[string]any{
+ "utterances": []map[string]any{
+ {"text": text, "definite": definite},
+ },
+ },
+ })
+ // 响应格式:4 字节 header + 8 字节跳过 + JSON
+ var buf []byte
+ buf = append(buf, 0x11, (0x0B<<4)|0x00, 0x00, 0x00) // header
+ buf = append(buf, 0, 0, 0, 0, 0, 0, 0, 0) // 8 bytes skipped
+ buf = append(buf, payload...)
+ return buf
+}
+
+func TestParseASRResult_DefiniteUtterance(t *testing.T) {
+ data := buildMockResponse(1000, "你好世界", true)
+ text, definite, done := parseASRResult(data)
+ if text != "你好世界" {
+ t.Errorf("text = %q, want '你好世界'", text)
+ }
+ if !definite {
+ t.Error("definite = false, want true")
+ }
+ if !done {
+ t.Error("done = false, want true")
+ }
+}
+
+func TestParseASRResult_IndefiniteUtterance(t *testing.T) {
+ data := buildMockResponse(1000, "你好", false)
+ text, definite, done := parseASRResult(data)
+ if text != "你好" {
+ t.Errorf("text = %q, want '你好' (intermediate result)", text)
+ }
+ if definite {
+ t.Error("definite = true, want false for non-definite")
+ }
+ if done {
+ t.Error("done = true, want false for non-definite")
+ }
+}
+
+func TestParseASRResult_NoSpeechCode(t *testing.T) {
+ data := buildMockResponse(1013, "", false)
+ text, _, done := parseASRResult(data)
+ if text != "" || !done {
+ t.Errorf("got text=%q done=%v, want empty/true for code 1013 (silent, session ends)", text, done)
+ }
+}
+
+func TestParseASRResult_TooShort(t *testing.T) {
+ _, _, done := parseASRResult([]byte{0x11, 0x00})
+ if done {
+ t.Error("expected done=false for short frame")
+ }
+}
+
+func TestParseASRResult_ServerError(t *testing.T) {
+ // msgType 0x0F in byte[1] upper nibble
+ data := []byte{0x11, (msgTypeServerError << 4), 0x00, 0x00, 0, 0, 0, 42, 0, 0, 0, 0}
+ _, _, done := parseASRResult(data)
+ if !done {
+ t.Error("expected done=true for server error frame")
+ }
+}
+
+// ---- checkErrorResponse ----
+
+func TestCheckErrorResponse_OK(t *testing.T) {
+ frame := buildFrame(0x0B, flagNormal, serialJSON, compressGZP, []byte("{}"))
+ if err := checkErrorResponse(frame); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestCheckErrorResponse_ServerError(t *testing.T) {
+ frame := []byte{0x11, (msgTypeServerError << 4) | 0x00, 0x00, 0x00, 0, 0, 0, 99, 0, 0, 0, 4}
+ if err := checkErrorResponse(frame); err == nil {
+ t.Error("expected error for server error frame")
+ }
+}
+
+func TestCheckErrorResponse_TooShort(t *testing.T) {
+ if err := checkErrorResponse([]byte{0x11}); err == nil {
+ t.Error("expected error for too-short frame")
+ }
+}
diff --git a/pkg/asr/funasr/provider.go b/pkg/asr/funasr/provider.go
new file mode 100644
index 000000000..b275a31c8
--- /dev/null
+++ b/pkg/asr/funasr/provider.go
@@ -0,0 +1,237 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// Package funasr 对接 FunASR WebSocket 服务(iic/SenseVoiceSmall 等模型)。
+// 支持实时流式 ASR(RealtimeProvider),中文识别效果优秀,完全本地部署,无需付费 API。
+//
+// 部署:
+//
+// docker compose -f docker/docker-compose.asr-tts.yml up -d funasr
+//
+// 配置(环境变量):
+//
+// PICOCLAW_VOICE_ASR_WS_URL WebSocket 地址,默认 wss://127.0.0.1:10095
+// PICOCLAW_VOICE_ASR_MODE 识别模式:2pass(默认)| online | offline
+package funasr
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/sipeed/picoclaw/pkg/asr"
+)
+
+func init() {
+ asr.Register("funasr", func(cfg map[string]any) (asr.Provider, error) {
+ return newProvider(cfg)
+ })
+}
+
+// 默认使用 WSS,FunASR SDK 镜像内置自签名证书
+const defaultWSURL = "wss://127.0.0.1:10095"
+
+type provider struct {
+ wsURL string
+ mode string // "2pass" | "online" | "offline"
+ dialer *websocket.Dialer
+}
+
+func newProvider(cfg map[string]any) (*provider, error) {
+ wsURL, _ := cfg["ws_url"].(string)
+ mode, _ := cfg["mode"].(string)
+ if wsURL == "" {
+ wsURL = defaultWSURL
+ }
+ if mode == "" {
+ mode = "2pass"
+ }
+ return &provider{
+ wsURL: wsURL,
+ mode: mode,
+ dialer: &websocket.Dialer{
+ HandshakeTimeout: 10 * time.Second,
+ // FunASR 使用自签名证书,跳过校验
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
+ },
+ }, nil
+}
+
+func (p *provider) Name() string { return "funasr" }
+
+func (p *provider) AudioFormat() asr.AudioFormat {
+ return asr.AudioFormat{Codec: "pcm", SampleRate: 16000, Channels: 1}
+}
+
+// Transcribe 批量模式:将所有帧合并后一次性送识别。
+// 使用 OpenSession 实现,以复用实时 ASR 连接逻辑。
+func (p *provider) Transcribe(ctx context.Context, frames [][]byte) (string, error) {
+ sess, err := p.OpenSession(ctx, func(_ string, _ bool) {})
+ if err != nil {
+ return "", err
+ }
+ defer sess.Close()
+
+ pcm := asr.MergeFrames(frames)
+ // 每次推送 3200 字节(100ms @ 16kHz 16-bit mono),匹配 FunASR 推荐块大小
+ const chunkSize = 3200
+ for i := 0; i < len(pcm); i += chunkSize {
+ end := i + chunkSize
+ if end > len(pcm) {
+ end = len(pcm)
+ }
+ if err := sess.SendAudio(pcm[i:end], end >= len(pcm)); err != nil {
+ return "", err
+ }
+ }
+ return sess.Wait(ctx)
+}
+
+// OpenSession 实现 RealtimeProvider:建立 WebSocket 连接,启动收包 goroutine。
+func (p *provider) OpenSession(ctx context.Context, callback asr.ResultCallback) (asr.StreamingSession, error) {
+ // FunASR WebSocket 服务要求 Sec-WebSocket-Protocol: binary
+ header := http.Header{"Sec-WebSocket-Protocol": {"binary"}}
+ conn, _, err := p.dialer.DialContext(ctx, p.wsURL, header)
+ if err != nil {
+ return nil, fmt.Errorf("funasr: dial %s: %w", p.wsURL, err)
+ }
+
+ // ctx 取消时关闭连接,使 ReadMessage 立即返回
+ go func() {
+ <-ctx.Done()
+ conn.Close()
+ }()
+
+ cfg := map[string]any{
+ "mode": p.mode,
+ "chunk_size": []int{5, 10, 5},
+ "chunk_interval": 10,
+ "wav_name": "picoclaw",
+ "is_speaking": true,
+ "wav_format": "pcm",
+ "itn": true,
+ }
+ cfgBytes, _ := json.Marshal(cfg)
+ if err := conn.WriteMessage(websocket.TextMessage, cfgBytes); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("funasr: send config: %w", err)
+ }
+
+ sess := &streamSession{
+ conn: conn,
+ callback: callback,
+ mode: p.mode,
+ resultCh: make(chan string, 1),
+ errCh: make(chan error, 1),
+ }
+ go sess.readLoop()
+ return sess, nil
+}
+
+// ── session ──────────────────────────────────────────────────────────────────
+
+type streamSession struct {
+ conn *websocket.Conn
+ callback asr.ResultCallback
+ mode string
+ mu sync.Mutex
+ closed bool
+ resultCh chan string
+ errCh chan error
+}
+
+type funasrResult struct {
+ Mode string `json:"mode"`
+ Text string `json:"text"`
+ IsFinal bool `json:"is_final"`
+}
+
+// readLoop 持续读取服务端推送的 JSON 识别结果。
+// 2pass 模式:2pass-online 为中间结果,2pass-offline 为最终高质量结果。
+// online/offline 模式:is_final=true 表示识别完成。
+func (s *streamSession) readLoop() {
+ for {
+ _, data, err := s.conn.ReadMessage()
+ if err != nil {
+ // ctx 取消时 conn.Close() 触发此处,属于正常退出路径
+ select {
+ case s.errCh <- fmt.Errorf("funasr: read: %w", err):
+ default:
+ }
+ return
+ }
+
+ var r funasrResult
+ if err := json.Unmarshal(data, &r); err != nil {
+ log.Printf("funasr: parse result: %v (raw: %s)", err, data)
+ continue
+ }
+
+ // 判断是否为最终结果:
+ // - 2pass-offline:FunASR 双路模式的最终离线结果(最高精度)
+ // - offline:纯离线单路模式,收到结果即终态
+ // - is_final=true:在线模式显式标记
+ isFinal := r.Mode == "2pass-offline" || r.Mode == "offline" || (r.Mode == "online" && r.IsFinal)
+
+ s.callback(r.Text, isFinal)
+
+ if isFinal {
+ select {
+ case s.resultCh <- r.Text:
+ default:
+ }
+ return
+ }
+ }
+}
+
+func (s *streamSession) SendAudio(frame []byte, isLast bool) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed {
+ return asr.ErrSessionClosed
+ }
+ if len(frame) > 0 {
+ if err := s.conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
+ return fmt.Errorf("funasr: send audio: %w", err)
+ }
+ }
+ if isLast {
+ end, _ := json.Marshal(map[string]any{"is_speaking": false})
+ if err := s.conn.WriteMessage(websocket.TextMessage, end); err != nil {
+ return fmt.Errorf("funasr: send end: %w", err)
+ }
+ }
+ return nil
+}
+
+func (s *streamSession) Wait(ctx context.Context) (string, error) {
+ select {
+ case text := <-s.resultCh:
+ return text, nil
+ case err := <-s.errCh:
+ if ctx.Err() != nil {
+ return "", asr.ErrSessionClosed
+ }
+ return "", err
+ case <-ctx.Done():
+ return "", asr.ErrSessionClosed
+ }
+}
+
+func (s *streamSession) Close() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.closed {
+ s.closed = true
+ s.conn.Close()
+ }
+}
diff --git a/pkg/asr/provider.go b/pkg/asr/provider.go
new file mode 100644
index 000000000..58b8c8f0b
--- /dev/null
+++ b/pkg/asr/provider.go
@@ -0,0 +1,102 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package asr
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+// AudioFormat describes the audio wire format a provider expects from the device.
+// The gateway uses this to populate helloReply.audio_params; devices must send accordingly.
+type AudioFormat struct {
+ Codec string // "pcm" | "opus"
+ SampleRate int // Hz, e.g. 16000
+ Channels int // 1 = mono
+}
+
+// MergeFrames concatenates audio frames into a single byte slice.
+// Useful for batch-mode providers that process all audio at once.
+func MergeFrames(frames [][]byte) []byte {
+ total := 0
+ for _, f := range frames {
+ total += len(f)
+ }
+ merged := make([]byte, 0, total)
+ for _, f := range frames {
+ merged = append(merged, f...)
+ }
+ return merged
+}
+
+// Provider is the ASR interface.
+type Provider interface {
+ Name() string
+ // AudioFormat returns the audio format this provider expects from the device.
+ // The gateway advertises this via helloReply so the device sends the correct format.
+ AudioFormat() AudioFormat
+ // Transcribe converts audio frames to text.
+ // frames: raw audio bytes in the format declared by AudioFormat().
+ Transcribe(ctx context.Context, frames [][]byte) (string, error)
+}
+
+// ResultCallback receives incremental ASR results.
+// final=true 表示识别完成(definite),后续不再有回调。
+type ResultCallback func(text string, final bool)
+
+// StreamingProvider extends Provider with batch streaming ASR support.
+// The callback is invoked each time the recognized text changes, and once more
+// with final=true when recognition is complete.
+type StreamingProvider interface {
+ Provider
+ TranscribeStream(ctx context.Context, frames [][]byte, callback ResultCallback) error
+}
+
+// ErrSessionClosed is returned by StreamingSession.Wait when the session
+// was closed before a final result was available.
+var ErrSessionClosed = errors.New("asr: session closed")
+
+// StreamingSession is an active real-time ASR session.
+// Open with RealtimeProvider.OpenSession; feed audio with SendAudio;
+// signal end-of-audio by passing isLast=true; then call Wait for the result.
+type StreamingSession interface {
+ // SendAudio pushes a raw audio frame. Set isLast=true on the final frame.
+ // frame: raw audio bytes in the format declared by the provider's AudioFormat().
+ SendAudio(frame []byte, isLast bool) error
+ // Wait blocks until the final recognition result is available.
+ Wait(ctx context.Context) (string, error)
+ // Close aborts the session and releases all resources.
+ Close()
+}
+
+// RealtimeProvider extends Provider with live frame-by-frame ASR.
+// Audio is fed incrementally as it arrives, reducing latency.
+type RealtimeProvider interface {
+ Provider
+ // OpenSession starts a new live ASR session.
+ // callback receives each incremental result (final=true on completion).
+ OpenSession(ctx context.Context, callback ResultCallback) (StreamingSession, error)
+}
+
+// Factory creates a Provider from a config map.
+type Factory func(cfg map[string]any) (Provider, error)
+
+var factories = map[string]Factory{}
+
+// Register adds a provider factory. Called from provider init() functions.
+func Register(name string, f Factory) {
+ factories[name] = f
+}
+
+// New creates a Provider by name.
+func New(name string, cfg map[string]any) (Provider, error) {
+ f, ok := factories[name]
+ if !ok {
+ return nil, fmt.Errorf("asr: provider %q not registered", name)
+ }
+ return f(cfg)
+}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 5c328f418..1f8104915 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -52,6 +52,17 @@ func (p *HTTPProvider) Chat(
return p.delegate.Chat(ctx, messages, tools, model, options)
}
+func (p *HTTPProvider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onToken func(string),
+) (*LLMResponse, error) {
+ return p.delegate.ChatStream(ctx, messages, tools, model, options, onToken)
+}
+
func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
diff --git a/pkg/providers/openai_compat/streaming.go b/pkg/providers/openai_compat/streaming.go
new file mode 100644
index 000000000..8e406f97a
--- /dev/null
+++ b/pkg/providers/openai_compat/streaming.go
@@ -0,0 +1,198 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package openai_compat
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+)
+
+// ChatStream implements providers.StreamingProvider on *Provider.
+// Text tokens are delivered via onToken as they arrive. Tool calls are
+// accumulated across chunks and returned in the final *LLMResponse.
+func (p *Provider) ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onToken func(string),
+) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+ model = normalizeModel(model, p.apiBase)
+
+ requestBody := map[string]any{
+ "model": model,
+ "messages": serializeMessages(messages),
+ "stream": true,
+ }
+ if len(tools) > 0 {
+ requestBody["tools"] = tools
+ requestBody["tool_choice"] = "auto"
+ }
+ if maxTokens, ok := asInt(options["max_tokens"]); ok {
+ fieldName := p.maxTokensField
+ if fieldName == "" {
+ lm := strings.ToLower(model)
+ if strings.Contains(lm, "glm") || strings.Contains(lm, "o1") || strings.Contains(lm, "gpt-5") {
+ fieldName = "max_completion_tokens"
+ } else {
+ fieldName = "max_tokens"
+ }
+ }
+ requestBody[fieldName] = maxTokens
+ }
+ if temperature, ok := asFloat(options["temperature"]); ok {
+ lm := strings.ToLower(model)
+ if strings.Contains(lm, "kimi") && strings.Contains(lm, "k2") {
+ requestBody["temperature"] = 1.0
+ } else {
+ requestBody["temperature"] = temperature
+ }
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+
+ // Use a client without a read timeout — context handles cancellation.
+ streamClient := &http.Client{Transport: p.httpClient.Transport}
+ resp, err := streamClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+ return nil, fmt.Errorf("API request failed: status=%d body=%s", resp.StatusCode, responsePreview(body, 128))
+ }
+
+ return parseStreamResponse(resp.Body, onToken)
+}
+
+type toolCallChunkBuilder struct {
+ id string
+ callType string
+ name string
+ args strings.Builder
+}
+
+func parseStreamResponse(body io.Reader, onToken func(string)) (*LLMResponse, error) {
+ var contentBuf strings.Builder
+ var finishReason string
+ builders := map[int]*toolCallChunkBuilder{}
+
+ scanner := bufio.NewScanner(body)
+ scanner.Buffer(make([]byte, 64*1024), 64*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimPrefix(line, "data: ")
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ ToolCalls []struct {
+ Index int `json:"index"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function *struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ } `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+ } `json:"choices"`
+ }
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ continue
+ }
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ choice := chunk.Choices[0]
+ if choice.FinishReason != nil {
+ finishReason = *choice.FinishReason
+ }
+ if choice.Delta.Content != "" {
+ contentBuf.WriteString(choice.Delta.Content)
+ if onToken != nil {
+ onToken(choice.Delta.Content)
+ }
+ }
+ for _, tc := range choice.Delta.ToolCalls {
+ b, ok := builders[tc.Index]
+ if !ok {
+ b = &toolCallChunkBuilder{}
+ builders[tc.Index] = b
+ }
+ if tc.ID != "" {
+ b.id = tc.ID
+ }
+ if tc.Type != "" {
+ b.callType = tc.Type
+ }
+ if tc.Function != nil {
+ if tc.Function.Name != "" {
+ b.name = tc.Function.Name
+ }
+ b.args.WriteString(tc.Function.Arguments)
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, fmt.Errorf("stream read error: %w", err)
+ }
+
+ toolCalls := make([]ToolCall, 0, len(builders))
+ for i := 0; i < len(builders); i++ {
+ b, ok := builders[i]
+ if !ok {
+ break
+ }
+ toolCalls = append(toolCalls, ToolCall{
+ ID: b.id,
+ Type: b.callType,
+ Name: b.name,
+ Function: &FunctionCall{
+ Name: b.name,
+ Arguments: b.args.String(),
+ },
+ })
+ }
+
+ return &LLMResponse{
+ Content: contentBuf.String(),
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ }, nil
+}
diff --git a/pkg/providers/streaming.go b/pkg/providers/streaming.go
new file mode 100644
index 000000000..2c4daaab2
--- /dev/null
+++ b/pkg/providers/streaming.go
@@ -0,0 +1,23 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import "context"
+
+// StreamingProvider extends LLMProvider with streaming capability.
+// Text tokens are delivered via onToken as they arrive from the API.
+// Tool calls are accumulated internally and returned in the final *LLMResponse.
+type StreamingProvider interface {
+ LLMProvider
+ ChatStream(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+ onToken func(string),
+ ) (*LLMResponse, error)
+}
diff --git a/pkg/tts/doubao/provider.go b/pkg/tts/doubao/provider.go
new file mode 100644
index 000000000..a080b5337
--- /dev/null
+++ b/pkg/tts/doubao/provider.go
@@ -0,0 +1,331 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// Package doubao 通过豆包(火山引擎)TTS WebSocket 流式接口合成语音。
+// 协议参考:wss://openspeech.bytedance.com/api/v1/tts/ws_binary
+//
+// 服务端帧类型(data[1]>>4):
+//
+// 0x0B(音频帧):4字节头 + 4字节seq + 4字节载荷长度 + Ogg Opus 数据
+// 0x0C(合成结束)/0x0F(错误):4字节头 + 4字节载荷长度 + gzip JSON
+//
+// 鉴权:Authorization: Bearer;{token}(火山引擎非标准分号格式)
+// 无 CGO,纯 Go 实现。
+package doubao
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/websocket"
+ "github.com/sipeed/picoclaw/pkg/tts"
+)
+
+const (
+ defaultWSURL = "wss://openspeech.bytedance.com/api/v1/tts/ws_binary"
+ defaultCluster = "volcano_tts"
+ defaultVoice = "zh_female_wanwanxiaohe_moon_bigtts"
+)
+
+// 二进制帧协议常量
+const (
+ msgTypeFullClientRequest = byte(0x01)
+ msgTypeServerAudio = byte(0x0B) // 音频帧,含 seq + payload_size
+ msgTypeServerError = byte(0x0F) // 错误帧,含 payload_size(无 seq)
+
+ flagNormal = byte(0x00)
+ serialJSON = byte(0x01)
+ compressGZP = byte(0x01)
+)
+
+func init() {
+ tts.Register("doubao", func(cfg map[string]any) (tts.Provider, error) {
+ return newProvider(cfg)
+ })
+}
+
+type provider struct {
+ appid string
+ accessToken string
+ cluster string
+ defaultVoice string
+ wsURL string
+ dialer *websocket.Dialer
+}
+
+func newProvider(cfg map[string]any) (*provider, error) {
+ get := func(key string) string {
+ v, _ := cfg[key].(string)
+ return v
+ }
+ p := &provider{
+ appid: get("appid"),
+ accessToken: get("access_token"),
+ cluster: get("cluster"),
+ defaultVoice: get("voice"),
+ wsURL: get("ws_url"),
+ }
+ if p.accessToken == "" {
+ return nil, fmt.Errorf("tts/doubao: access_token is required")
+ }
+ if p.cluster == "" {
+ p.cluster = defaultCluster
+ }
+ if p.defaultVoice == "" {
+ p.defaultVoice = defaultVoice
+ }
+ if p.wsURL == "" {
+ p.wsURL = defaultWSURL
+ }
+ // 不走代理直连火山引擎,避免本地 http_proxy 拦截 WebSocket。
+ p.dialer = &websocket.Dialer{
+ NetDialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ HandshakeTimeout: 10 * time.Second,
+ }
+ return p, nil
+}
+
+func (p *provider) Name() string { return "doubao" }
+
+func (p *provider) AudioFormat() tts.AudioFormat {
+ return tts.AudioFormat{Codec: "opus", SampleRate: 16000, Channels: 1}
+}
+
+// SynthesizeFrames 通过豆包 TTS WebSocket 流式接口合成语音。
+// 连接建立后立即发送合成请求(含文字),服务端边合成边推送原始 Opus 帧,
+// 每帧到达即触发 onFrame 回调,无需等待整句合成完成。
+func (p *provider) SynthesizeFrames(ctx context.Context, text, voice string, onFrame func([]byte)) error {
+ if voice == "" {
+ voice = p.defaultVoice
+ }
+
+ // 火山引擎 TTS WS 接口要求 Authorization: Bearer;{token}(分号分隔,非标准空格)。
+ // appid 在 JSON 请求体 app.appid 里传,Bearer 头仅携带 token。
+ headers := http.Header{
+ "Authorization": {"Bearer;" + p.accessToken},
+ "X-Api-Connect-Id": {uuid.New().String()},
+ }
+
+ conn, resp, err := p.dialer.DialContext(ctx, p.wsURL, headers)
+ if err != nil {
+ if resp != nil {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ resp.Body.Close()
+ return fmt.Errorf("tts/doubao: dial: %w (HTTP %d: %s)", err, resp.StatusCode, bytes.TrimSpace(body))
+ }
+ return fmt.Errorf("tts/doubao: dial: %w", err)
+ }
+ defer conn.Close()
+
+ // ctx 取消时关闭连接,使 ReadMessage 立即解除阻塞。
+ go func() {
+ <-ctx.Done()
+ conn.Close()
+ }()
+
+ // 发送合成请求帧(包含完整文字;operation="submit" 触发流式推送)
+ // 注意:火山引擎 TTS 二进制 WS 协议中 app.token 需加 "access_" 前缀,
+ // 而 HTTP Authorization Bearer 使用原始 token。
+ reqPayload := map[string]any{
+ "app": map[string]any{
+ "appid": p.appid,
+ "token": "access_" + p.accessToken,
+ "cluster": p.cluster,
+ },
+ "user": map[string]any{"uid": "picoclaw"},
+ "audio": map[string]any{
+ "voice_type": voice,
+ // ogg_opus:服务端推送 Ogg 容器包装的 Opus 数据;
+ // 服务端 API encoding 与内部 AudioFormat codec 解耦,
+ // 此处通过 io.Pipe + ParseOggOpusPackets 流式解包后回调原始 Opus 帧。
+ "encoding": "ogg_opus",
+ "rate": 16000,
+ "channel": 1,
+ },
+ "request": map[string]any{
+ "reqid": uuid.New().String(),
+ "text": text,
+ "text_type": "plain",
+ "operation": "submit", // submit = 流式推送;query = HTTP 一次性返回
+ "with_frontend": 1,
+ "frontend_type": "unitTson",
+ },
+ }
+ initFrame, err := buildTTSFrame(reqPayload)
+ if err != nil {
+ return fmt.Errorf("tts/doubao: build request frame: %w", err)
+ }
+ if err := conn.WriteMessage(websocket.BinaryMessage, initFrame); err != nil {
+ return fmt.Errorf("tts/doubao: send request: %w", err)
+ }
+
+ // 用 io.Pipe 将流式 Ogg 载荷接入 ParseOggOpusPackets:
+ // 主循环写 → 解析协程读,边收 WS 帧边解包 Opus,实现真正流式回调。
+ pr, pw := io.Pipe()
+ parseErrCh := make(chan error, 1)
+ go func() {
+ parseErrCh <- tts.ParseOggOpusPackets(pr, onFrame)
+ }()
+
+ frameCount := 0
+ for {
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ if ctx.Err() != nil {
+ pw.CloseWithError(ctx.Err())
+ return ctx.Err()
+ }
+ pw.CloseWithError(err)
+ break
+ }
+ audio, isLast, parseErr := parseTTSFrame(frameCount, msg)
+ if parseErr != nil {
+ pw.CloseWithError(parseErr)
+ return parseErr
+ }
+ if len(audio) > 0 {
+ frameCount++
+ if _, werr := pw.Write(audio); werr != nil {
+ return werr
+ }
+ }
+ if isLast {
+ pw.Close()
+ break
+ }
+ }
+
+ if err := <-parseErrCh; err != nil && ctx.Err() == nil {
+ return err
+ }
+ log.Printf("tts/doubao: streamed %d ogg pages for %q", frameCount, truncate(text, 20))
+ return nil
+}
+
+// parseTTSFrame 解析豆包 TTS WebSocket 服务端响应帧。
+//
+// 帧类型由 data[1]>>4 决定,不同类型结构不同:
+//
+// msgType=0x0B(音频帧):[4:8]=seq int32(负=末帧), [8:12]=载荷长度, [12:]=Ogg数据
+// msgType=其他(0x0C合成结束/0x0F错误):[4:8]=载荷长度, [8:]=gzip JSON,无 seq 字段
+func parseTTSFrame(idx int, data []byte) (audio []byte, isLast bool, err error) {
+ if len(data) < 4 {
+ return nil, false, fmt.Errorf("tts/doubao: frame too short (%d bytes)", len(data))
+ }
+
+ msgType := data[1] >> 4
+ compressType := data[2] & 0x0F
+
+ if msgType != 0x0B {
+ // 非音频帧(0x0C=合成结束,0x0F=服务端错误):[4:8]=载荷长度,[8:]=gzip JSON
+ if len(data) >= 8 {
+ payloadSize := binary.BigEndian.Uint32(data[4:8])
+ end := 8 + int(payloadSize)
+ if end <= len(data) {
+ payload := data[8:end]
+ if compressType == compressGZP {
+ if decoded, e := gunzipBytes(payload); e == nil {
+ payload = decoded
+ }
+ }
+ if len(payload) > 2 && payload[0] == '{' {
+ var resp struct {
+ Code int `json:"code"`
+ }
+ if json.Unmarshal(payload, &resp) == nil && resp.Code != 0 && resp.Code != 200 {
+ s := string(payload)
+ if len(s) > 256 {
+ s = s[:256]
+ }
+ log.Printf("tts/doubao: frame[%d] server error: %s", idx, s)
+ return nil, true, fmt.Errorf("tts/doubao: server error: %s", s)
+ }
+ }
+ }
+ }
+ return nil, true, nil // 非音频帧均视为流结束信号
+ }
+
+ // msgType=0x0B:音频帧,[4:8]=seq,[8:12]=载荷长度,[12:]=Ogg数据
+ if len(data) == 8 {
+ seq := int32(binary.BigEndian.Uint32(data[4:8]))
+ return nil, seq < 0, nil
+ }
+ if len(data) < 12 {
+ return nil, false, fmt.Errorf("tts/doubao: audio frame too short (%d bytes)", len(data))
+ }
+ seq := int32(binary.BigEndian.Uint32(data[4:8]))
+ payloadSize := binary.BigEndian.Uint32(data[8:12])
+ end := 12 + int(payloadSize)
+ if end > len(data) {
+ return nil, false, fmt.Errorf("tts/doubao: payload size %d exceeds frame length %d", payloadSize, len(data))
+ }
+ return data[12:end], seq < 0, nil
+}
+
+// buildTTSFrame 将 JSON payload gzip 压缩后封装为豆包二进制协议帧(客户端 → 服务端)。
+func buildTTSFrame(payload any) ([]byte, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ compressed, err := gzipBytes(data)
+ if err != nil {
+ return nil, err
+ }
+ hdr := [4]byte{
+ (0x01 << 4) | 0x01,
+ (msgTypeFullClientRequest << 4) | flagNormal,
+ (serialJSON << 4) | compressGZP,
+ 0x00,
+ }
+ frame := make([]byte, 0, 8+len(compressed))
+ frame = append(frame, hdr[:]...)
+ frame = binary.BigEndian.AppendUint32(frame, uint32(len(compressed)))
+ frame = append(frame, compressed...)
+ return frame, nil
+}
+
+func gunzipBytes(data []byte) ([]byte, error) {
+ r, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ defer r.Close()
+ return io.ReadAll(r)
+}
+
+func gzipBytes(data []byte) ([]byte, error) {
+ var buf bytes.Buffer
+ w := gzip.NewWriter(&buf)
+ if _, err := w.Write(data); err != nil {
+ return nil, err
+ }
+ if err := w.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func truncate(s string, n int) string {
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ return string(runes[:n]) + "…"
+}
diff --git a/pkg/tts/fishspeech/provider.go b/pkg/tts/fishspeech/provider.go
new file mode 100644
index 000000000..84dbbc800
--- /dev/null
+++ b/pkg/tts/fishspeech/provider.go
@@ -0,0 +1,227 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+// Package fishspeech 对接 Fish Speech v1.5.x HTTP TTS 服务。
+// POST /v1/tts 输出 WAV(streaming=true),流式读取 PCM 帧后直接下发客户端。
+// 无需 CGO,完全纯 Go。
+//
+// 部署:
+//
+// docker compose -f docker/docker-compose.asr-tts.yml up -d fishspeech
+//
+// 配置(环境变量):
+//
+// PICOCLAW_VOICE_TTS_API_URL 服务地址,默认 http://127.0.0.1:8080
+// PICOCLAW_VOICE_TTS_API_KEY Bearer Token,本地部署通常无需填写
+// PICOCLAW_VOICE_TTS_REFERENCE_ID 参考音色 ID(留空使用服务默认音色)
+// PICOCLAW_VOICE_TTS_SAMPLE_RATE 输出采样率,默认 44100(Fish Speech v1.5.x 默认值)
+package fishspeech
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/tts"
+)
+
+func init() {
+ tts.Register("fishspeech", func(cfg map[string]any) (tts.Provider, error) {
+ return newProvider(cfg)
+ })
+}
+
+const (
+ defaultAPIBase = "http://127.0.0.1:8080"
+ defaultSampleRate = 44100
+ // 每次回调约 46ms 的 PCM(@44100Hz mono s16le)
+ pcmChunkBytes = 4096
+ // 用于生成参考音频的固定短句(中性内容,音色稳定)
+ refGenText = "你好,我是你的语音助手。"
+)
+
+// refAudio 缓存:首次合成时用 seed 生成一段参考 WAV,后续请求带上它以固定音色。
+type refAudio struct {
+ once sync.Once
+ wav []byte // RIFF WAV 文件(streaming=false, format=wav)
+ ready bool
+}
+
+type provider struct {
+ apiBase string
+ apiKey string
+ referenceID string
+ sampleRate int
+ seed int // 0 = 随机音色;非 0 = 首次合成后锁定参考音频
+ ref refAudio
+ client *http.Client
+}
+
+func newProvider(cfg map[string]any) (*provider, error) {
+ apiBase, _ := cfg["api_url"].(string)
+ apiKey, _ := cfg["api_key"].(string)
+ referenceID, _ := cfg["reference_id"].(string)
+ if apiBase == "" {
+ apiBase = defaultAPIBase
+ }
+ sampleRate := defaultSampleRate
+ if sr, ok := cfg["sample_rate"].(int); ok && sr > 0 {
+ sampleRate = sr
+ }
+ seed, _ := cfg["seed"].(int)
+ return &provider{
+ apiBase: apiBase,
+ apiKey: apiKey,
+ referenceID: referenceID,
+ sampleRate: sampleRate,
+ seed: seed,
+ // 仅设置连接建立超时;response body 读取时间由 ctx 控制(streaming=true 时 body 无期限流式输出)
+ client: &http.Client{
+ Transport: &http.Transport{
+ DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
+ },
+ },
+ }, nil
+}
+
+func (p *provider) Name() string { return "fishspeech" }
+
+// AudioFormat 声明输出格式为 PCM s16le,采样率由配置指定(默认 44100Hz mono)。
+// 网关通过 tts_params 下发给客户端,客户端据此初始化播放设备,无需 Opus 解码。
+func (p *provider) AudioFormat() tts.AudioFormat {
+ return tts.AudioFormat{Codec: "pcm", SampleRate: p.sampleRate, Channels: 1}
+}
+
+// SynthesizeFrames 向 Fish Speech HTTP API 发请求,流式读取原始 PCM s16le 数据分块回调。
+// 若配置了 seed,首次调用会先用固定短句生成参考音频并缓存,后续每次合成都带上该参考,
+// 从而保证不同句子的音色一致。
+func (p *provider) SynthesizeFrames(ctx context.Context, text, voice string, onFrame func([]byte)) error {
+ refID := voice
+ if refID == "" {
+ refID = p.referenceID
+ }
+
+ // seed 非 0 且没有外部 reference_id 时,使用内部参考音频固定音色
+ var refWAV []byte
+ if p.seed != 0 && refID == "" {
+ p.ref.once.Do(func() {
+ wav, err := p.generateRefWAV()
+ if err != nil {
+ log.Printf("tts/fishspeech: generate ref audio: %v (voice may vary)", err)
+ return
+ }
+ p.ref.wav = wav
+ p.ref.ready = true
+ })
+ if p.ref.ready {
+ refWAV = p.ref.wav
+ }
+ }
+
+ payload := map[string]any{
+ "text": text,
+ "streaming": true,
+ "chunk_length": 100,
+ }
+ if refID != "" {
+ payload["reference_id"] = refID
+ } else if len(refWAV) > 0 {
+ // 将缓存的参考 WAV 编码为 base64,作为 in-context speaker 固定音色
+ payload["references"] = []map[string]any{
+ {
+ "audio": base64.StdEncoding.EncodeToString(refWAV),
+ "text": refGenText,
+ },
+ }
+ } else if p.seed != 0 {
+ // 参考音频未就绪(生成失败)时退化为 seed 模式
+ payload["seed"] = p.seed
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("tts/fishspeech: marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+"/v1/tts", bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("tts/fishspeech: create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("tts/fishspeech: http: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("tts/fishspeech: http %d: %s", resp.StatusCode, b)
+ }
+
+ // Fish Speech streaming=true 直接返回裸 PCM s16le,无 RIFF 头
+ buf := make([]byte, pcmChunkBytes)
+ for {
+ n, err := io.ReadFull(resp.Body, buf)
+ if n > 0 {
+ chunk := make([]byte, n)
+ copy(chunk, buf[:n])
+ onFrame(chunk)
+ }
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ break
+ }
+ if err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ return fmt.Errorf("tts/fishspeech: read: %w", err)
+ }
+ }
+ return nil
+}
+
+// generateRefWAV 用 refGenText + seed 生成一段参考 WAV,streaming=false 以获取完整文件。
+func (p *provider) generateRefWAV() ([]byte, error) {
+ payload := map[string]any{
+ "text": refGenText,
+ "streaming": false,
+ "format": "wav",
+ "seed": p.seed,
+ }
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest(http.MethodPost, p.apiBase+"/v1/tts", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+ resp, err := p.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
+ return nil, fmt.Errorf("http %d: %s", resp.StatusCode, b)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+
diff --git a/pkg/tts/ogg.go b/pkg/tts/ogg.go
new file mode 100644
index 000000000..92b6721a3
--- /dev/null
+++ b/pkg/tts/ogg.go
@@ -0,0 +1,57 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package tts
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+
+ "github.com/pion/opus/pkg/oggreader"
+)
+
+// ParseOggOpusPackets 从 Ogg Opus 流中解析完整 Opus 包并逐个回调。
+//
+// 正确处理 Ogg lacing:pion/oggreader 的 ParseNextPage 按原始 segment(最多 255 字节)
+// 切割返回,一个 Opus 包若 > 255 字节会横跨多个 segment。本函数按 Ogg lacing 规则
+// 拼接这些 segment,确保每次 onPacket 回调都是一个完整可解码的 Opus 包。
+//
+// oggreader.NewWith 内部会消耗第一个 Ogg 页(OpusHead),因此本函数从第一个
+// ParseNextPage 开始,跳过 OpusTags 注释页后直接处理音频数据页。
+func ParseOggOpusPackets(r io.Reader, onPacket func([]byte)) error {
+ ogg, _, err := oggreader.NewWith(r)
+ if err != nil {
+ return fmt.Errorf("tts/ogg: init reader: %w", err)
+ }
+ var buf []byte
+ for {
+ segments, _, pageErr := ogg.ParseNextPage()
+ if pageErr == io.EOF {
+ break
+ }
+ if pageErr != nil {
+ return fmt.Errorf("tts/ogg: parse page: %w", pageErr)
+ }
+ // 跳过 OpusTags 注释头页
+ if len(segments) > 0 && bytes.HasPrefix(segments[0], []byte("OpusTags")) {
+ continue
+ }
+ // 按 Ogg lacing 规则拼接 segments 为完整 Opus 包:
+ // segment 长度 == 255 表示当前包未结束;< 255 表示当前包的最后一个 segment。
+ for _, seg := range segments {
+ buf = append(buf, seg...)
+ if len(seg) < 255 {
+ if len(buf) > 0 {
+ pkt := make([]byte, len(buf))
+ copy(pkt, buf)
+ onPacket(pkt)
+ buf = buf[:0]
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/pkg/tts/provider.go b/pkg/tts/provider.go
new file mode 100644
index 000000000..faaa2d07e
--- /dev/null
+++ b/pkg/tts/provider.go
@@ -0,0 +1,49 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package tts
+
+import (
+ "context"
+ "fmt"
+)
+
+// AudioFormat 描述 TTS provider 的输出音频格式,由 provider 自身声明。
+// 网关通过 helloReply.tts_params 下发给客户端,客户端据此初始化解码器。
+type AudioFormat struct {
+ Codec string // 编码格式,如 "opus"、"pcm"
+ SampleRate int // 采样率,如 16000
+ Channels int // 声道数,1 = 单声道
+}
+
+// Provider is the TTS interface.
+// AudioFormat 由 provider 自身声明;SynthesizeFrames 输出与之对应格式的帧。
+type Provider interface {
+ Name() string
+ // AudioFormat 返回本 provider 的输出格式,用于向客户端协商解码参数。
+ AudioFormat() AudioFormat
+ // SynthesizeFrames 将文本合成为音频,每帧通过 onFrame 回调返回。
+ // 帧格式由 AudioFormat() 声明;voice 为空时使用 provider 默认音色。
+ SynthesizeFrames(ctx context.Context, text, voice string, onFrame func([]byte)) error
+}
+
+// Factory creates a Provider from a config map.
+type Factory func(cfg map[string]any) (Provider, error)
+
+var factories = map[string]Factory{}
+
+// Register adds a provider factory. Called from provider init() functions.
+func Register(name string, f Factory) {
+ factories[name] = f
+}
+
+// New creates a Provider by name.
+func New(name string, cfg map[string]any) (Provider, error) {
+ f, ok := factories[name]
+ if !ok {
+ return nil, fmt.Errorf("tts: provider %q not registered", name)
+ }
+ return f(cfg)
+}