# PLAN.md — openclaw-proxy

## 项目定位

轻量级异步 WebSocket 网关，部署在 Linux 云服务器上，负责在 **小智 ESP32 语音硬件** 与 **OpenClaw 智能体** (`/root/.openclaw/`) 之间做协议翻译和管线编排。

```
┌──────────────┐  Opus/JSON   ┌──────────────────┐   CLI    ┌──────────────┐
│  XiaoZhi     │◄────────────►│  openclaw-proxy  │────────►│  OpenClaw    │
│  ESP32       │  WebSocket   │  (FastAPI:8000)   │  stdout  │  Gateway     │
└──────────────┘              └──────────────────┘◄────────│  (:18789)    │
                                  │        ▲                └──────────────┘
                                  ▼        │
                              ┌──────┐ ┌──────┐
                              │ STT  │ │ TTS  │
                              └──────┘ └──────┘
```

**对接方式决策（2026-03-15）：** 采用方案 B — 通过 `openclaw agent` CLI 调用 gateway，
不修改 `.openclaw/` 目录，不注册 channel 插件。优先跑通演示。

## 协议约定

### 上行（ESP32 → Proxy）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "hardware_event", "action": "wake_word\|key_down\|key_up"}` | 硬件状态事件 |
| 二进制帧 | Raw Opus bytes | 用户语音音频流 |

### 下行（Proxy → ESP32）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "state", "status": "listening\|thinking\|speaking"}` | 控制硬件 LED 状态 |
| 文本帧 | `{"type": "text_reply", "text": "..."}` | LLM 文本回复 |
| 二进制帧 | Raw Opus bytes | TTS 合成音频流 |

## 分阶段实施计划

---

### Phase 0 — Mock 骨架搭建 ✅ 已完成

**目标：** 跑通 WebSocket 握手和完整数据通路，用假数据验证时序正确。

**交付物：**
- [x] `main.py` — FastAPI + WS `/chat` 端点，帧路由，三段 mock 管线
- [x] `tests/test_main.py` — 8 个测试全部通过（3 单元 + 5 集成）
- [x] `requirements.txt` — 依赖声明

---

### Phase 0.5 — OpenClaw 真实对接 ✅ 已完成

**目标：** 将 `call_openclaw` 从 mock 替换为真实 CLI 调用，跑通文本对话。

**决策：** 采用 `openclaw agent --agent main --session-id {id} -m {text} --json` 命令。
Gateway 运行在 `localhost:18789`，CLI 自动连接。

**交付物：**
- [x] `call_openclaw` 通过 `asyncio.create_subprocess_exec` 调用 CLI
- [x] 每个 WebSocket 连接分配独立 `session_id`，支持多轮对话
- [x] LLM 推理期间每 3 秒发送 thinking 心跳，保持硬件活跃
- [x] CLI 失败 / 空 payload 的错误处理
- [x] `tests/test_main.py` — 10 个测试全部通过（mock subprocess）

**管线流程：**
```
Binary frame → process_stt(mock) → send "thinking" state
            → call_openclaw(CLI) → heartbeat every 3s → send text_reply
            → process_tts(mock) → send binary audio
```

---

### Phase 1 — 音频缓冲与真实 ESP32 握手

**目标：** 支持 ESP32 的真实交互模式——用户按住按键持续说话，松开后触发管线。

**任务：**
- [ ] 实现 `AudioBuffer` 类，按连接维护音频缓冲区
  - `key_down` / `wake_word` → 开始缓冲，进入 "listening" 状态
  - 后续二进制帧 → 追加到缓冲区
  - `key_up` → 停止缓冲，将完整音频交给管线
- [ ] 重构 `handle_binary_frame` 为缓冲追加逻辑
- [ ] 新增 `handle_pipeline(ws, audio_buffer)` 统一编排三段管线
- [ ] 处理边界情况：空音频、超长音频、重复 key_down
- [ ] 补充对应测试用例

**关键数据结构：**
```python
class SessionState(BaseModel):
    status: Literal["idle", "listening", "processing"] = "idle"
    audio_chunks: list[bytes] = []
```

---

### Phase 2 — 真实 STT 接入

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**任务：**
- [ ] 选型确认（FunASR / SenseVoice / Whisper API 三选一）
- [ ] 实现 Opus → WAV 转码
  - 方案 A：`ffmpeg-python` 子进程管道（推荐，无需编译 C 扩展）
  - 方案 B：`pydub` + `ffmpeg` 系统命令
- [ ] 实现真实 STT 调用，返回识别文本
- [ ] 处理空音频 / 识别失败的降级逻辑
- [ ] 补充测试（mock 外部服务，验证转码和调用逻辑）

**依赖：** 服务器需安装 `ffmpeg` 系统包。

---

### Phase 3 — ~~OpenClaw 智能体对接~~ ✅ 已在 Phase 0.5 完成

已通过 `openclaw agent` CLI 完成对接，无需额外工作。
后续可考虑升级为 channel 插件获得更紧密集成。

---

### Phase 4 — 真实 TTS 接入

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**任务：**
- [ ] 选型确认（edge-tts / CosyVoice / GPT-SoVITS 三选一）
- [ ] 实现文本 → WAV → Opus 合成管线
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知
- [ ] 补充测试

**分块推流时序：**
```
send state("speaking")
for chunk in opus_chunks:
    send binary(chunk)
    await asyncio.sleep(frame_duration)
send state("idle")
```

---

### Phase 5 — 生产加固

**目标：** 具备长期稳定运行的能力。

**任务：**
- [ ] 连接管理：最大并发数限制、心跳超时断开、异常重连
- [ ] 配置外置：`config.yaml` 或环境变量管理端口、模型选择、API 密钥等
- [ ] 结构化日志：JSON 格式日志，接入日志收集
- [ ] 指标监控：`/metrics` 端点暴露连接数、管线延迟、错误率
- [ ] systemd 服务单元文件，支持开机自启和自动重启
- [ ] HTTPS/WSS 支持（通过 nginx 反代或 uvicorn SSL 参数）
- [ ] HTTP 管理接口：`GET /sessions` 查看在线连接、`POST /broadcast` 广播消息

---

## 技术栈总览

| 层 | 当前（Phase 0） | 目标 |
|----|-----------------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | mock | FunASR / SenseVoice / Whisper |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | mock (静音帧) | edge-tts / CosyVoice |
| 音频编解码 | 无 | ffmpeg-python |
| 部署 | `python main.py` | systemd + nginx |

## 文件结构规划

```
openclaw-proxy/
├── main.py              # FastAPI 应用入口、WS 端点、帧路由
├── pipeline/
│   ├── __init__.py
│   ├── stt.py           # process_stt 实现
│   ├── llm.py           # call_openclaw 实现
│   └── tts.py           # process_tts 实现
├── models.py            # Pydantic 模型集中定义
├── session.py           # 连接会话状态管理、音频缓冲
├── config.py            # 配置加载（环境变量 / yaml）
├── tests/
│   ├── test_main.py
│   ├── test_stt.py
│   ├── test_llm.py
│   └── test_tts.py
├── requirements.txt
├── CLAUDE.md
└── PLAN.md
```

> 注：Phase 0 阶段全部逻辑集中在 `main.py`，从 Phase 1 开始按上述结构拆分模块。


     1→"""Quick WebSocket client to test the full proxy pipeline."""
     2→
     3→import asyncio
     4→import json
     5→import websockets
     6→
     7→
     8→async def main() -> None:
     9→    uri = "ws://localhost:8000/chat"
    10→    async with websockets.connect(uri) as ws:
    11→        print("=== Connected ===\n")
    12→
    13→        # 1) Test text frame: wake_word
    14→        print("[TX] wake_word")
    15→        await ws.send(json.dumps({"type": "hardware_event", "action": "wake_word"}))
    16→        resp = json.loads(await ws.recv())
    17→        print(f"[RX] {resp}\n")
    18→
    19→        # 2) Test binary frame: triggers STT → LLM → TTS pipeline
    20→        print("[TX] binary audio (80 bytes of mock Opus)")
    21→        await ws.send(b"\x00" * 80)
    22→
    23→        # Collect all responses until we get binary audio back
    24→        while True:
    25→            msg = await ws.recv()
    26→            if isinstance(msg, bytes):
    27→                print(f"[RX] binary audio: {len(msg)} bytes")
    28→                break
    29→            else:
    30→                data = json.loads(msg)
    31→                print(f"[RX] {data}")
    32→
    33→        print("\n=== Done ===")
    34→
    35→
    36→if __name__ == "__main__":
    37→    asyncio.run(main())
    38→

"""Quick WebSocket client to test the full proxy pipeline."""

import asyncio
import json
import websockets

async def main() -> None:
    uri = "ws://localhost:8000/chat"
    async with websockets.connect(uri) as ws:
        print("=== Connected ===\n")

        # 1) Test text frame: wake_word
        print("[TX] wake_word")
        await ws.send(json.dumps({"type": "hardware_event", "action": "wake_word"}))
        resp = json.loads(await ws.recv())
        print(f"[RX] {resp}\n")

        # 2) Test binary frame: triggers STT → LLM → TTS pipeline
        print("[TX] binary audio (80 bytes of mock Opus)")
        await ws.send(b"\x00" * 80)

        # Collect all responses until we get binary audio back
        while True:
            msg = await ws.recv()
            if isinstance(msg, bytes):
                print(f"[RX] binary audio: {len(msg)} bytes")
                break
            else:
                data = json.loads(msg)
                print(f"[RX] {data}")

        print("\n=== Done ===")

if __name__ == "__main__":
    asyncio.run(main())


Now I have all the context. Let me write a comprehensive milestone document. The user wants it to be as detailed as possible. Let me write it in Chinese since the project documentation is primarily in Chinese, and the user communicates in Chinese.

The milestone should cover:
1. What was accomplished (Phase 1 - Real STT + TTS)
2. Technical details of the implementation
3. Architecture and data flow
4. Code changes with file-level details
5. Test coverage
6. Dependencies
7. Configuration
8. Known limitations and risks
9. How to verify/test
10. What's next

Let me write this as `docs/milestone-real-stt-tts.md` or just update PLAN.md. The user said "milestone文档", so I'll create a dedicated milestone doc. Let me put it at the project root as `MILESTONE-STT-TTS.md`.

I'll create the detailed milestone document in Chinese and also update PLAN.md to reflect the completed phases.


Now let me write the milestone document and update PLAN.md.


Write

# Milestone: Real STT + TTS Integration

> **日期：** 2026-03-16
> **阶段：** Phase 2 + Phase 4（合并完成）
> **状态：** ✅ 单元测试全部通过，等待实机验证

---

## 1. 概述

本次 milestone 将 openclaw-proxy 的 STT（语音识别）和 TTS（语音合成）从 mock 替换为真实实现，打通完整的语音链路：

```
ESP32 麦克风 → Opus 音频 → [STT: Whisper] → 文字
    → [LLM: OpenClaw CLI] → 回复文字
    → [TTS: edge-tts + ffmpeg] → Opus 音频 → ESP32 扬声器
```

**之前（Phase 0.5）：** STT 固定返回 `"测试语音输入"`，TTS 固定返回 159 字节静音帧。
**现在：** STT 使用 Whisper base 模型做真实中文语音识别，TTS 使用 edge-tts 合成语音并通过 ffmpeg 编码为 Opus。

---

## 2. 技术选型与理由

### 2.1 STT — OpenAI Whisper (base)

| 项目 | 选择 |
|------|------|
| 引擎 | `openai-whisper` 20250625 |
| 模型 | `base`（74M 参数，~140MB 磁盘） |
| 运行方式 | 本地 CPU 推理（无 GPU） |
| 语言 | 固定 `zh`（中文） |

**为什么选 Whisper base：**
- 4GB 内存 + 4 核 CPU 环境下，`base` 是精度和速度的最佳平衡点
- `tiny` 太不准确，`small` 在 CPU 上太慢（>10s/句）
- 本地运行，无需外部 API，无网络延迟
- Whisper 内置 ffmpeg 调用，能直接处理 Opus 格式输入

**不选 FunASR / SenseVoice 的原因：**
- 依赖更重，安装复杂
- Whisper 已满足演示需求，后续可替换

### 2.2 TTS — edge-tts + ffmpeg

| 项目 | 选择 |
|------|------|
| 合成引擎 | `edge-tts` 7.2.7（微软 Edge 在线 TTS） |
| 声音 | `zh-CN-XiaoxiaoNeural`（女声，自然度高） |
| 输出格式 | MP3（edge-tts 原生输出） |
| 编码转换 | `ffmpeg` → Opus（16kHz, mono, 24kbps） |

**为什么选 edge-tts：**
- 零成本、无需 API key
- 异步原生（`async for` 流式获取音频）
- 中文语音质量极佳（微软 Neural TTS）
- 依赖轻量，纯 Python + aiohttp

**不选 CosyVoice / GPT-SoVITS 的原因：**
- 需要 GPU 或大量 CPU 资源
- 部署复杂度高，不适合当前 4GB 内存环境

---

## 3. 架构变更

### 3.1 变更前后对比

```
变更前（Phase 0.5）：
┌─────────────┐     ┌──────────────────┐     ┌──────────────┐
│  ESP32      │────►│  process_stt()   │     │  OpenClaw    │
│  Opus bytes │     │  return 固定文字  │────►│  CLI         │
└─────────────┘     └──────────────────┘     └──────┬───────┘
                    ┌──────────────────┐            │
                    │  process_tts()   │◄───────────┘
                    │  return 静音帧   │
                    └──────────────────┘

变更后（本次 Milestone）：
┌─────────────┐     ┌──────────────────────────────────┐     ┌──────────────┐
│  ESP32      │────►│  process_stt()                   │     │  OpenClaw    │
│  Opus bytes │     │  tmpfile(.opus) → Whisper base   │────►│  CLI         │
└─────────────┘     │  → asyncio.to_thread() → 文本    │     └──────┬───────┘
                    └──────────────────────────────────┘            │
                    ┌──────────────────────────────────┐            │
                    │  process_tts()                   │◄───────────┘
                    │  edge-tts → MP3 → ffmpeg → Opus  │
                    └──────────────────────────────────┘
```

### 3.2 数据流详细说明

#### STT 链路：`process_stt(audio_bytes: bytes) -> str`

```
1. ESP32 发送原始 Opus 音频字节
2. 写入临时文件（NamedTemporaryFile, suffix=".opus"）
3. Whisper load_audio() 内部调用 ffmpeg 将 Opus 解码为 16kHz float32 PCM
4. Whisper base 模型推理（通过 asyncio.to_thread 在线程池执行，不阻塞事件循环）
5. 提取 result["text"]，strip() 去除首尾空白
6. 返回识别文本
```

**关键设计决策：**
- **Lazy loading：** Whisper 模型不在 `import` 时加载，而是首次调用 `process_stt()` 时通过 `_get_whisper_model()` 延迟加载。这样做的好处：
  - 测试不需要加载真实模型（mock `_get_whisper_model` 即可）
  - 服务启动快，模型加载延迟到第一个请求
  - 模型全局复用，只加载一次
- **asyncio.to_thread：** Whisper 推理是 CPU 密集型操作，直接在事件循环中运行会阻塞所有 WebSocket 连接。用 `to_thread` 将其放到线程池执行。
- **临时文件：** Whisper 的 `transcribe()` 接受文件路径（内部用 ffmpeg 解码），不接受 bytes。因此需要写临时文件。Linux 上 `NamedTemporaryFile(delete=True)` 在 `with` 块内文件可通过路径访问，退出后自动删除。

#### TTS 链路：`process_tts(text: str) -> bytes`

```
1. 创建 edge_tts.Communicate(text=text, voice="zh-CN-XiaoxiaoNeural")
2. async for 遍历 communicate.stream()，收集所有 type="audio" 的 chunk
3. 将 MP3 chunks 拼接为完整 MP3 字节
4. 启动 ffmpeg 子进程进行格式转换：
   ffmpeg -i pipe:0 -c:a libopus -b:a 24k -ar 16000 -ac 1 -f opus pipe:1
   - 输入：stdin 管道接收 MP3 字节
   - 输出：stdout 管道输出 Opus 字节
5. 检查 ffmpeg 返回码，非零则抛出 RuntimeError
6. 返回 Opus 字节
```

**关键设计决策：**
- **管道传输：** MP3 → Opus 转换通过 stdin/stdout 管道完成，无需写中间文件。
- **ffmpeg 参数：** `libopus` 编码器，24kbps 码率（语音足够），16kHz 采样率，单声道。与 ESP32 硬件解码能力匹配。
- **错误处理：** ffmpeg 失败时读取 stderr 日志并抛出 RuntimeError，上层可捕获处理。

---

## 4. 代码变更详情

### 4.1 修改的文件

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `main.py` | 修改 | 替换 mock STT/TTS 为真实实现 |
| `tests/test_main.py` | 修改 | 更新测试用例匹配新实现 |
| `requirements.txt` | 修改 | 新增 whisper 和 edge-tts 依赖 |

### 4.2 `main.py` 变更明细

#### 新增导入（第 1-14 行）

```python
import tempfile          # STT 临时文件
import edge_tts          # TTS 引擎
import whisper           # STT 引擎
```

#### 新增配置常量（第 29-31 行）

```python
WHISPER_MODEL_SIZE = "base"                    # Whisper 模型大小
EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural"       # TTS 声音
FFMPEG_BIN = which("ffmpeg") or "ffmpeg"       # ffmpeg 可执行文件路径
```

#### 新增 Whisper 模型懒加载（第 66-77 行）

```python
_whisper_model: whisper.Whisper | None = None

def _get_whisper_model() -> whisper.Whisper:
    """Return the Whisper model, loading it on first call."""
    global _whisper_model
    if _whisper_model is None:
        logger.info("Loading Whisper '%s' model…", WHISPER_MODEL_SIZE)
        _whisper_model = whisper.load_model(WHISPER_MODEL_SIZE)
    return _whisper_model
```

#### 替换 `process_stt`（第 83-102 行）

**之前：** 固定返回 `"测试语音输入"`（2 行逻辑）
**之后：** 完整 Whisper 推理流程（12 行逻辑）

```python
async def process_stt(audio_bytes: bytes) -> str:
    logger.info("STT: received %d bytes of Opus audio", len(audio_bytes))
    model = _get_whisper_model()
    with tempfile.NamedTemporaryFile(suffix=".opus", delete=True) as f:
        f.write(audio_bytes)
        f.flush()
        result = await asyncio.to_thread(
            model.transcribe, f.name, language="zh"
        )
    text = result["text"].strip()
    logger.info("STT: transcribed → %s", text)
    return text
```

#### 替换 `process_tts`（第 147-187 行）

**之前：** 固定返回 `b"\xfc\xff\xfe" * 53` 静音帧（3 行逻辑）
**之后：** edge-tts 合成 + ffmpeg Opus 编码（25 行逻辑）

```python
async def process_tts(text: str) -> bytes:
    logger.info("TTS: synthesising → %s", text)

    # 1. edge-tts → MP3
    communicate = edge_tts.Communicate(text=text, voice=EDGE_TTS_VOICE)
    mp3_chunks: list[bytes] = []
    async for chunk in communicate.stream():
        if chunk["type"] == "audio":
            mp3_chunks.append(chunk["data"])
    mp3_bytes = b"".join(mp3_chunks)

    # 2. MP3 → Opus via ffmpeg
    proc = await asyncio.create_subprocess_exec(
        FFMPEG_BIN, "-i", "pipe:0",
        "-c:a", "libopus", "-b:a", "24k",
        "-ar", "16000", "-ac", "1",
        "-f", "opus", "pipe:1",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    opus_bytes, stderr = await proc.communicate(input=mp3_bytes)

    if proc.returncode != 0:
        err_msg = stderr.decode(errors="replace").strip()
        logger.error("ffmpeg failed (rc=%d): %s", proc.returncode, err_msg)
        raise RuntimeError(f"ffmpeg TTS conversion error: {err_msg}")

    logger.info("TTS: generated %d bytes of Opus audio", len(opus_bytes))
    return opus_bytes
```

#### 未变更的部分

以下代码**完全未修改**：
- Pydantic 模型（`HardwareAction`, `HardwareEvent`, `StateNotification`, `TextReply`）
- `call_openclaw()` — 仍通过 CLI 调用 OpenClaw
- `handle_text_frame()` — 文本帧路由
- `handle_binary_frame()` — 二进制帧管线编排（STT → LLM → TTS 时序不变）
- `websocket_chat()` — WebSocket 端点
- `/health` — 健康检查
- 入口点 — uvicorn 启动

### 4.3 `tests/test_main.py` 变更明细

#### 新增导入

```python
from unittest.mock import MagicMock  # 用于 mock Whisper 模型（同步对象）
```

#### 替换的测试

| 旧测试 | 新测试 | 变更说明 |
|--------|--------|----------|
| `test_process_stt_returns_mock_text` | `test_process_stt_calls_whisper` | mock `_get_whisper_model`，验证临时文件、语言参数、文本 strip |
| `test_process_tts_returns_bytes` | `test_process_tts_returns_opus_bytes` | mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和 Opus 输出 |

#### 新增的测试

| 测试 | 说明 |
|------|------|
| `test_process_tts_raises_on_ffmpeg_failure` | ffmpeg 返回非零退出码时应抛出 `RuntimeError` |

#### 修改的测试

| 测试 | 变更说明 |
|------|----------|
| `test_websocket_binary_frame_full_pipeline` | 从 mock subprocess 改为 mock 三个管线函数（`process_stt`, `call_openclaw`, `process_tts`），更好地隔离 WebSocket 集成测试 |

#### 未变更的测试（6 个）

- `test_call_openclaw_parses_cli_output`
- `test_call_openclaw_raises_on_cli_failure`
- `test_call_openclaw_raises_on_empty_payloads`
- `test_health_endpoint`
- `test_websocket_connect_and_disconnect`
- `test_websocket_text_frame_wake_word`
- `test_websocket_text_frame_malformed_json`

### 4.4 `requirements.txt` 变更

```diff
 fastapi>=0.115.0
 uvicorn[standard]>=0.34.0
 websockets>=14.0
 pydantic>=2.0
+openai-whisper>=20250625
+edge-tts>=7.0
 pytest>=8.0
 httpx>=0.28.0
 pytest-asyncio>=0.25.0
```

---

## 5. 测试结果

```
$ python -m pytest tests/ -v

tests/test_main.py::test_process_stt_calls_whisper PASSED            [  9%]
tests/test_main.py::test_call_openclaw_parses_cli_output PASSED      [ 18%]
tests/test_main.py::test_call_openclaw_raises_on_cli_failure PASSED  [ 27%]
tests/test_main.py::test_call_openclaw_raises_on_empty_payloads PASSED [ 36%]
tests/test_main.py::test_process_tts_returns_opus_bytes PASSED       [ 45%]
tests/test_main.py::test_process_tts_raises_on_ffmpeg_failure PASSED [ 54%]
tests/test_main.py::test_health_endpoint PASSED                      [ 63%]
tests/test_main.py::test_websocket_connect_and_disconnect PASSED     [ 72%]
tests/test_main.py::test_websocket_text_frame_wake_word PASSED       [ 81%]
tests/test_main.py::test_websocket_text_frame_malformed_json PASSED  [ 90%]
tests/test_main.py::test_websocket_binary_frame_full_pipeline PASSED [100%]

============================== 11 passed in 2.11s ==============================
```

**全部 11 个测试通过**（原 10 个 + 新增 1 个 ffmpeg 错误处理测试）。

---

## 6. 依赖清单

### 运行时依赖

| 包 | 版本 | 用途 | 安装大小 |
|----|------|------|----------|
| `openai-whisper` | 20250625 | 语音识别引擎 | ~10MB（代码） |
| `torch` | 2.10.0 | Whisper 推理后端 | ~2GB（含 CUDA stubs） |
| `edge-tts` | 7.2.7 | 微软 TTS 合成 | ~50KB |
| `aiohttp` | 3.13+ | edge-tts 的 HTTP 传输层 | ~1MB |

### 系统依赖

| 工具 | 版本 | 用途 |
|------|------|------|
| `ffmpeg` | 7.0.2 | Opus ↔ WAV/MP3 音频转码 |
| `libopus` | （含于 ffmpeg） | Opus 编解码器 |

### Whisper 模型文件

| 模型 | 参数量 | 磁盘 | 内存 | 首次加载 |
|------|--------|------|------|----------|
| `base` | 74M | ~140MB | ~300MB | 需联网下载到 `~/.cache/whisper/` |

---

## 7. 配置参数

所有配置集中在 `main.py` 顶部 Configuration 区域：

```python
# ── Configuration ────────────────────────────────────────────────────
OPENCLAW_CLI = which("openclaw") or "openclaw"    # OpenClaw CLI 路径
OPENCLAW_AGENT_ID = "main"                         # 使用的 agent ID
OPENCLAW_TIMEOUT = 120                             # CLI 超时（秒）
WHISPER_MODEL_SIZE = "base"                        # Whisper 模型：tiny/base/small/medium/large
EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural"           # TTS 声音选择
FFMPEG_BIN = which("ffmpeg") or "ffmpeg"           # ffmpeg 路径
```

**可调参数说明：**

| 参数 | 可选值 | 影响 |
|------|--------|------|
| `WHISPER_MODEL_SIZE` | `tiny` / `base` / `small` / `medium` / `large` | 模型越大越准但越慢。`base` 在 4 核 CPU 上约 2-5 秒/句 |
| `EDGE_TTS_VOICE` | 见 `edge-tts --list-voices` | 可选其他中文声音如 `zh-CN-YunxiNeural`（男声）|
| ffmpeg `-b:a` | `16k` / `24k` / `32k` / `64k` | Opus 码率，`24k` 适合语音 |

---

## 8. 性能预估

基于 4 核 CPU / 4GB 内存 / 无 GPU 环境：

| 阶段 | 预估延迟 | 瓶颈 |
|------|----------|------|
| **首次 STT 调用** | 10-30 秒 | 下载 Whisper base 模型（~140MB，仅一次） |
| **STT 推理** | 2-5 秒/句 | CPU 推理，取决于音频长度 |
| **LLM（OpenClaw）** | 5-30 秒 | 取决于 agent 复杂度和外部 API |
| **TTS 合成** | 1-3 秒 | edge-tts 网络请求 + ffmpeg 转码 |
| **总延迟** | 8-38 秒 | 首次更长，后续稳定在 8-15 秒 |

**内存占用估算：**
- Whisper base 模型：~300MB
- FastAPI + Python 运行时：~200MB
- 峰值（推理中）：~500-600MB
- 剩余可用：~3.4GB（充裕）

---

## 9. 已知限制与风险

### 9.1 当前限制

| 限制 | 说明 | 影响 |
|------|------|------|
| **无音频缓冲** | 当前每个二进制帧独立触发完整管线 | ESP32 按住说话时，每个音频片段会分别处理而非合并。需要 Phase 1 的 `AudioBuffer` 实现 |
| **无分块推流** | TTS 音频一次性整包发送 | 长句子的音频可能有数十 KB，ESP32 需要一次性接收全部数据后才开始播放 |
| **单并发限制** | Whisper 推理会占用 CPU，多个同时请求会排队 | 当前单用户场景下不是问题 |
| **网络依赖** | edge-tts 需要访问微软服务器 | 服务器需有外网访问能力 |

### 9.2 风险项

| 风险 | 概率 | 缓解措施 |
|------|------|----------|
| Whisper 模型首次下载失败 | 中 | 可手动下载放到 `~/.cache/whisper/`，或预热 |
| edge-tts 微软服务不稳定 | 低 | 可降级为本地 TTS 或重试 |
| ESP32 发送的 Opus 格式不兼容 | 中 | ffmpeg 通常能处理，如不行需检查 ESP32 编码参数 |
| 长音频导致 Whisper OOM | 低 | base 模型在 4GB 环境处理 <30 秒音频没问题 |

---

## 10. 实机测试计划

### 10.1 前置准备

```bash
# 1. 确保服务器环境就绪
source venv/bin/activate
python -m pytest tests/ -v          # 所有测试通过
ffmpeg -version                      # 确认 ffmpeg 可用
python -c "import whisper; print(whisper.__version__)"   # 确认 whisper 可导入

# 2. 预热 Whisper 模型（避免首次调用等待下载）
python -c "import whisper; whisper.load_model('base')"

# 3. 确认 OpenClaw gateway 运行中
openclaw gateway status              # 或检查 :18789 端口

# 4. 启动 proxy
python main.py
```

### 10.2 模拟测试（无需 ESP32）

```bash
# 用 test_client.py 进行基本连通测试
python test_client.py
```

预期输出：
```
=== Connected ===

[TX] wake_word
[RX] {'type': 'state', 'status': 'listening'}

[TX] binary audio (80 bytes of mock Opus)
[RX] {'type': 'state', 'status': 'thinking'}
[RX] {'type': 'text_reply', 'text': '...（OpenClaw 回复）'}
[RX] binary audio: XXXX bytes

=== Done ===
```

> **注意：** `test_client.py` 发送的是 80 字节零填充 mock 数据，Whisper 可能识别为空或乱码。这是正常的——真实 Opus 音频才能产生有意义的识别结果。

### 10.3 ESP32 实机测试检查清单

- [ ] ESP32 小智固件已配置 WebSocket 地址为 `ws://<服务器IP>:8000/chat`
- [ ] 服务器防火墙已开放 8000 端口
- [ ] 按下按钮 → 服务器日志显示 `Hardware event: key_down`
- [ ] 对着麦克风说中文 → 服务器日志显示 `STT: transcribed → <识别的文字>`
- [ ] 松开按钮 → 服务器日志显示 `STT: received N bytes` 和 `LLM: sending to OpenClaw`
- [ ] ESP32 扬声器播放 TTS 合成的语音回复
- [ ] 多轮对话：连续提问，agent 能记住上下文（同一 session_id）

### 10.4 日志关键字速查

正常流程的日志序列：
```
STT: received XXXX bytes of Opus audio
Loading Whisper 'base' model…              ← 仅首次出现
STT: transcribed → 你好                    ← 识别结果
LLM: sending to OpenClaw → 你好
LLM: reply ← 你好！有什么我可以帮忙的？
TTS: synthesising → 你好！有什么我可以帮忙的？
TTS: generated XXXX bytes of Opus audio
Pushing XXXX bytes of audio to client
```

异常情况日志：
```
ffmpeg failed (rc=1): ...                  ← TTS ffmpeg 转码失败
OpenClaw CLI failed (rc=N): ...            ← LLM CLI 调用失败
```

---

## 11. 文件最终状态

### `main.py`（309 行）

```
第 1-14 行    — 导入（新增 tempfile, whisper, edge_tts）
第 16-22 行   — 日志配置
第 24-31 行   — 配置常量（新增 WHISPER_MODEL_SIZE, EDGE_TTS_VOICE, FFMPEG_BIN）
第 34-63 行   — Pydantic 模型（未变）
第 66-77 行   — Whisper 模型懒加载（新增）
第 80-102 行  — process_stt()（真实 Whisper 实现）
第 105-144 行 — call_openclaw()（未变）
第 147-187 行 — process_tts()（真实 edge-tts + ffmpeg 实现）
第 190-261 行 — 帧路由 + 管线编排（未变）
第 264-309 行 — FastAPI 应用 + 入口点（未变）
```

### `tests/test_main.py`（212 行）

```
11 个测试用例：
  - 1 个 STT 单元测试（重写）
  - 3 个 LLM 单元测试（未变）
  - 2 个 TTS 单元测试（1 个重写 + 1 个新增）
  - 5 个集成测试（1 个修改 mock 策略，4 个未变）
```

### `requirements.txt`（9 行）

```
新增 2 个依赖：openai-whisper, edge-tts
```

---

## 12. 与 PLAN.md 阶段映射

| PLAN.md 阶段 | 状态 | 本次完成内容 |
|-------------|------|-------------|
| Phase 0 — Mock 骨架 | ✅ 已完成 | — |
| Phase 0.5 — OpenClaw 对接 | ✅ 已完成 | — |
| Phase 1 — 音频缓冲 | ⏳ 待做 | — |
| **Phase 2 — 真实 STT** | **✅ 本次完成** | **Whisper base 模型接入** |
| Phase 3 — OpenClaw 对接 | ✅ 已在 0.5 完成 | — |
| **Phase 4 — 真实 TTS** | **✅ 本次完成** | **edge-tts + ffmpeg Opus 编码** |
| Phase 5 — 生产加固 | ⏳ 待做 | — |

**下一步：** Phase 1（音频缓冲），支持 ESP32 的 press-to-talk 交互模式——用户按住按键持续说话，松开后将完整音频合并送入管线。

---

## 13. 快速验证命令

```bash
# 进入虚拟环境
source venv/bin/activate

# 跑单元测试（应全部通过）
python -m pytest tests/ -v

# 启动服务（另一个终端）
python main.py

# 模拟客户端测试
python test_client.py
```


File created successfully at: /root/projects/openclaw-proxy/MILESTONE-STT-TTS.md

# Milestone: Real STT + TTS Integration

> **日期：** 2026-03-16
> **阶段：** Phase 2 + Phase 4（合并完成）
> **状态：** ✅ 单元测试全部通过，等待实机验证

---

## 1. 概述

本次 milestone 将 openclaw-proxy 的 STT（语音识别）和 TTS（语音合成）从 mock 替换为真实实现，打通完整的语音链路：

```
ESP32 麦克风 → Opus 音频 → [STT: Whisper] → 文字
    → [LLM: OpenClaw CLI] → 回复文字
    → [TTS: edge-tts + ffmpeg] → Opus 音频 → ESP32 扬声器
```

**之前（Phase 0.5）：** STT 固定返回 `"测试语音输入"`，TTS 固定返回 159 字节静音帧。
**现在：** STT 使用 Whisper base 模型做真实中文语音识别，TTS 使用 edge-tts 合成语音并通过 ffmpeg 编码为 Opus。

---

## 2. 技术选型与理由

### 2.1 STT — OpenAI Whisper (base)

| 项目 | 选择 |
|------|------|
| 引擎 | `openai-whisper` 20250625 |
| 模型 | `base`（74M 参数，~140MB 磁盘） |
| 运行方式 | 本地 CPU 推理（无 GPU） |
| 语言 | 固定 `zh`（中文） |

**为什么选 Whisper base：**
- 4GB 内存 + 4 核 CPU 环境下，`base` 是精度和速度的最佳平衡点
- `tiny` 太不准确，`small` 在 CPU 上太慢（>10s/句）
- 本地运行，无需外部 API，无网络延迟
- Whisper 内置 ffmpeg 调用，能直接处理 Opus 格式输入

**不选 FunASR / SenseVoice 的原因：**
- 依赖更重，安装复杂
- Whisper 已满足演示需求，后续可替换

### 2.2 TTS — edge-tts + ffmpeg

| 项目 | 选择 |
|------|------|
| 合成引擎 | `edge-tts` 7.2.7（微软 Edge 在线 TTS） |
| 声音 | `zh-CN-XiaoxiaoNeural`（女声，自然度高） |
| 输出格式 | MP3（edge-tts 原生输出） |
| 编码转换 | `ffmpeg` → Opus（16kHz, mono, 24kbps） |

**为什么选 edge-tts：**
- 零成本、无需 API key
- 异步原生（`async for` 流式获取音频）
- 中文语音质量极佳（微软 Neural TTS）
- 依赖轻量，纯 Python + aiohttp

**不选 CosyVoice / GPT-SoVITS 的原因：**
- 需要 GPU 或大量 CPU 资源
- 部署复杂度高，不适合当前 4GB 内存环境

---

## 3. 架构变更

### 3.1 变更前后对比

```
变更前（Phase 0.5）：
┌─────────────┐     ┌──────────────────┐     ┌──────────────┐
│  ESP32      │────►│  process_stt()   │     │  OpenClaw    │
│  Opus bytes │     │  return 固定文字  │────►│  CLI         │
└─────────────┘     └──────────────────┘     └──────┬───────┘
                    ┌──────────────────┐            │
                    │  process_tts()   │◄───────────┘
                    │  return 静音帧   │
                    └──────────────────┘

变更后（本次 Milestone）：
┌─────────────┐     ┌──────────────────────────────────┐     ┌──────────────┐
│  ESP32      │────►│  process_stt()                   │     │  OpenClaw    │
│  Opus bytes │     │  tmpfile(.opus) → Whisper base   │────►│  CLI         │
└─────────────┘     │  → asyncio.to_thread() → 文本    │     └──────┬───────┘
                    └──────────────────────────────────┘            │
                    ┌──────────────────────────────────┐            │
                    │  process_tts()                   │◄───────────┘
                    │  edge-tts → MP3 → ffmpeg → Opus  │
                    └──────────────────────────────────┘
```

### 3.2 数据流详细说明

#### STT 链路：`process_stt(audio_bytes: bytes) -> str`

```
1. ESP32 发送原始 Opus 音频字节
2. 写入临时文件（NamedTemporaryFile, suffix=".opus"）
3. Whisper load_audio() 内部调用 ffmpeg 将 Opus 解码为 16kHz float32 PCM
4. Whisper base 模型推理（通过 asyncio.to_thread 在线程池执行，不阻塞事件循环）
5. 提取 result["text"]，strip() 去除首尾空白
6. 返回识别文本
```

**关键设计决策：**
- **Lazy loading：** Whisper 模型不在 `import` 时加载，而是首次调用 `process_stt()` 时通过 `_get_whisper_model()` 延迟加载。这样做的好处：
  - 测试不需要加载真实模型（mock `_get_whisper_model` 即可）
  - 服务启动快，模型加载延迟到第一个请求
  - 模型全局复用，只加载一次
- **asyncio.to_thread：** Whisper 推理是 CPU 密集型操作，直接在事件循环中运行会阻塞所有 WebSocket 连接。用 `to_thread` 将其放到线程池执行。
- **临时文件：** Whisper 的 `transcribe()` 接受文件路径（内部用 ffmpeg 解码），不接受 bytes。因此需要写临时文件。Linux 上 `NamedTemporaryFile(delete=True)` 在 `with` 块内文件可通过路径访问，退出后自动删除。

#### TTS 链路：`process_tts(text: str) -> bytes`

```
1. 创建 edge_tts.Communicate(text=text, voice="zh-CN-XiaoxiaoNeural")
2. async for 遍历 communicate.stream()，收集所有 type="audio" 的 chunk
3. 将 MP3 chunks 拼接为完整 MP3 字节
4. 启动 ffmpeg 子进程进行格式转换：
   ffmpeg -i pipe:0 -c:a libopus -b:a 24k -ar 16000 -ac 1 -f opus pipe:1
   - 输入：stdin 管道接收 MP3 字节
   - 输出：stdout 管道输出 Opus 字节
5. 检查 ffmpeg 返回码，非零则抛出 RuntimeError
6. 返回 Opus 字节
```

**关键设计决策：**
- **管道传输：** MP3 → Opus 转换通过 stdin/stdout 管道完成，无需写中间文件。
- **ffmpeg 参数：** `libopus` 编码器，24kbps 码率（语音足够），16kHz 采样率，单声道。与 ESP32 硬件解码能力匹配。
- **错误处理：** ffmpeg 失败时读取 stderr 日志并抛出 RuntimeError，上层可捕获处理。

---

## 4. 代码变更详情

### 4.1 修改的文件

| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `main.py` | 修改 | 替换 mock STT/TTS 为真实实现 |
| `tests/test_main.py` | 修改 | 更新测试用例匹配新实现 |
| `requirements.txt` | 修改 | 新增 whisper 和 edge-tts 依赖 |

### 4.2 `main.py` 变更明细

#### 新增导入（第 1-14 行）

```python
import tempfile          # STT 临时文件
import edge_tts          # TTS 引擎
import whisper           # STT 引擎
```

#### 新增配置常量（第 29-31 行）

```python
WHISPER_MODEL_SIZE = "base"                    # Whisper 模型大小
EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural"       # TTS 声音
FFMPEG_BIN = which("ffmpeg") or "ffmpeg"       # ffmpeg 可执行文件路径
```

#### 新增 Whisper 模型懒加载（第 66-77 行）

```python
_whisper_model: whisper.Whisper | None = None

def _get_whisper_model() -> whisper.Whisper:
    """Return the Whisper model, loading it on first call."""
    global _whisper_model
    if _whisper_model is None:
        logger.info("Loading Whisper '%s' model…", WHISPER_MODEL_SIZE)
        _whisper_model = whisper.load_model(WHISPER_MODEL_SIZE)
    return _whisper_model
```

#### 替换 `process_stt`（第 83-102 行）

**之前：** 固定返回 `"测试语音输入"`（2 行逻辑）
**之后：** 完整 Whisper 推理流程（12 行逻辑）

```python
async def process_stt(audio_bytes: bytes) -> str:
    logger.info("STT: received %d bytes of Opus audio", len(audio_bytes))
    model = _get_whisper_model()
    with tempfile.NamedTemporaryFile(suffix=".opus", delete=True) as f:
        f.write(audio_bytes)
        f.flush()
        result = await asyncio.to_thread(
            model.transcribe, f.name, language="zh"
        )
    text = result["text"].strip()
    logger.info("STT: transcribed → %s", text)
    return text
```

#### 替换 `process_tts`（第 147-187 行）

**之前：** 固定返回 `b"\xfc\xff\xfe" * 53` 静音帧（3 行逻辑）
**之后：** edge-tts 合成 + ffmpeg Opus 编码（25 行逻辑）

```python
async def process_tts(text: str) -> bytes:
    logger.info("TTS: synthesising → %s", text)

    # 1. edge-tts → MP3
    communicate = edge_tts.Communicate(text=text, voice=EDGE_TTS_VOICE)
    mp3_chunks: list[bytes] = []
    async for chunk in communicate.stream():
        if chunk["type"] == "audio":
            mp3_chunks.append(chunk["data"])
    mp3_bytes = b"".join(mp3_chunks)

    # 2. MP3 → Opus via ffmpeg
    proc = await asyncio.create_subprocess_exec(
        FFMPEG_BIN, "-i", "pipe:0",
        "-c:a", "libopus", "-b:a", "24k",
        "-ar", "16000", "-ac", "1",
        "-f", "opus", "pipe:1",
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    opus_bytes, stderr = await proc.communicate(input=mp3_bytes)

    if proc.returncode != 0:
        err_msg = stderr.decode(errors="replace").strip()
        logger.error("ffmpeg failed (rc=%d): %s", proc.returncode, err_msg)
        raise RuntimeError(f"ffmpeg TTS conversion error: {err_msg}")

    logger.info("TTS: generated %d bytes of Opus audio", len(opus_bytes))
    return opus_bytes
```

#### 未变更的部分

以下代码**完全未修改**：
- Pydantic 模型（`HardwareAction`, `HardwareEvent`, `StateNotification`, `TextReply`）
- `call_openclaw()` — 仍通过 CLI 调用 OpenClaw
- `handle_text_frame()` — 文本帧路由
- `handle_binary_frame()` — 二进制帧管线编排（STT → LLM → TTS 时序不变）
- `websocket_chat()` — WebSocket 端点
- `/health` — 健康检查
- 入口点 — uvicorn 启动

### 4.3 `tests/test_main.py` 变更明细

#### 新增导入

```python
from unittest.mock import MagicMock  # 用于 mock Whisper 模型（同步对象）
```

#### 替换的测试

| 旧测试 | 新测试 | 变更说明 |
|--------|--------|----------|
| `test_process_stt_returns_mock_text` | `test_process_stt_calls_whisper` | mock `_get_whisper_model`，验证临时文件、语言参数、文本 strip |
| `test_process_tts_returns_bytes` | `test_process_tts_returns_opus_bytes` | mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和 Opus 输出 |

#### 新增的测试

| 测试 | 说明 |
|------|------|
| `test_process_tts_raises_on_ffmpeg_failure` | ffmpeg 返回非零退出码时应抛出 `RuntimeError` |

#### 修改的测试

| 测试 | 变更说明 |
|------|----------|
| `test_websocket_binary_frame_full_pipeline` | 从 mock subprocess 改为 mock 三个管线函数（`process_stt`, `call_openclaw`, `process_tts`），更好地隔离 WebSocket 集成测试 |

#### 未变更的测试（6 个）

- `test_call_openclaw_parses_cli_output`
- `test_call_openclaw_raises_on_cli_failure`
- `test_call_openclaw_raises_on_empty_payloads`
- `test_health_endpoint`
- `test_websocket_connect_and_disconnect`
- `test_websocket_text_frame_wake_word`
- `test_websocket_text_frame_malformed_json`

### 4.4 `requirements.txt` 变更

```diff
 fastapi>=0.115.0
 uvicorn[standard]>=0.34.0
 websockets>=14.0
 pydantic>=2.0
+openai-whisper>=20250625
+edge-tts>=7.0
 pytest>=8.0
 httpx>=0.28.0
 pytest-asyncio>=0.25.0
```

---

## 5. 测试结果

```
$ python -m pytest tests/ -v

tests/test_main.py::test_process_stt_calls_whisper PASSED            [  9%]
tests/test_main.py::test_call_openclaw_parses_cli_output PASSED      [ 18%]
tests/test_main.py::test_call_openclaw_raises_on_cli_failure PASSED  [ 27%]
tests/test_main.py::test_call_openclaw_raises_on_empty_payloads PASSED [ 36%]
tests/test_main.py::test_process_tts_returns_opus_bytes PASSED       [ 45%]
tests/test_main.py::test_process_tts_raises_on_ffmpeg_failure PASSED [ 54%]
tests/test_main.py::test_health_endpoint PASSED                      [ 63%]
tests/test_main.py::test_websocket_connect_and_disconnect PASSED     [ 72%]
tests/test_main.py::test_websocket_text_frame_wake_word PASSED       [ 81%]
tests/test_main.py::test_websocket_text_frame_malformed_json PASSED  [ 90%]
tests/test_main.py::test_websocket_binary_frame_full_pipeline PASSED [100%]

============================== 11 passed in 2.11s ==============================
```

**全部 11 个测试通过**（原 10 个 + 新增 1 个 ffmpeg 错误处理测试）。

---

## 6. 依赖清单

### 运行时依赖

| 包 | 版本 | 用途 | 安装大小 |
|----|------|------|----------|
| `openai-whisper` | 20250625 | 语音识别引擎 | ~10MB（代码） |
| `torch` | 2.10.0 | Whisper 推理后端 | ~2GB（含 CUDA stubs） |
| `edge-tts` | 7.2.7 | 微软 TTS 合成 | ~50KB |
| `aiohttp` | 3.13+ | edge-tts 的 HTTP 传输层 | ~1MB |

### 系统依赖

| 工具 | 版本 | 用途 |
|------|------|------|
| `ffmpeg` | 7.0.2 | Opus ↔ WAV/MP3 音频转码 |
| `libopus` | （含于 ffmpeg） | Opus 编解码器 |

### Whisper 模型文件

| 模型 | 参数量 | 磁盘 | 内存 | 首次加载 |
|------|--------|------|------|----------|
| `base` | 74M | ~140MB | ~300MB | 需联网下载到 `~/.cache/whisper/` |

---

## 7. 配置参数

所有配置集中在 `main.py` 顶部 Configuration 区域：

```python
# ── Configuration ────────────────────────────────────────────────────
OPENCLAW_CLI = which("openclaw") or "openclaw"    # OpenClaw CLI 路径
OPENCLAW_AGENT_ID = "main"                         # 使用的 agent ID
OPENCLAW_TIMEOUT = 120                             # CLI 超时（秒）
WHISPER_MODEL_SIZE = "base"                        # Whisper 模型：tiny/base/small/medium/large
EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural"           # TTS 声音选择
FFMPEG_BIN = which("ffmpeg") or "ffmpeg"           # ffmpeg 路径
```

**可调参数说明：**

| 参数 | 可选值 | 影响 |
|------|--------|------|
| `WHISPER_MODEL_SIZE` | `tiny` / `base` / `small` / `medium` / `large` | 模型越大越准但越慢。`base` 在 4 核 CPU 上约 2-5 秒/句 |
| `EDGE_TTS_VOICE` | 见 `edge-tts --list-voices` | 可选其他中文声音如 `zh-CN-YunxiNeural`（男声）|
| ffmpeg `-b:a` | `16k` / `24k` / `32k` / `64k` | Opus 码率，`24k` 适合语音 |

---

## 8. 性能预估

基于 4 核 CPU / 4GB 内存 / 无 GPU 环境：

| 阶段 | 预估延迟 | 瓶颈 |
|------|----------|------|
| **首次 STT 调用** | 10-30 秒 | 下载 Whisper base 模型（~140MB，仅一次） |
| **STT 推理** | 2-5 秒/句 | CPU 推理，取决于音频长度 |
| **LLM（OpenClaw）** | 5-30 秒 | 取决于 agent 复杂度和外部 API |
| **TTS 合成** | 1-3 秒 | edge-tts 网络请求 + ffmpeg 转码 |
| **总延迟** | 8-38 秒 | 首次更长，后续稳定在 8-15 秒 |

**内存占用估算：**
- Whisper base 模型：~300MB
- FastAPI + Python 运行时：~200MB
- 峰值（推理中）：~500-600MB
- 剩余可用：~3.4GB（充裕）

---

## 9. 已知限制与风险

### 9.1 当前限制

| 限制 | 说明 | 影响 |
|------|------|------|
| **无音频缓冲** | 当前每个二进制帧独立触发完整管线 | ESP32 按住说话时，每个音频片段会分别处理而非合并。需要 Phase 1 的 `AudioBuffer` 实现 |
| **无分块推流** | TTS 音频一次性整包发送 | 长句子的音频可能有数十 KB，ESP32 需要一次性接收全部数据后才开始播放 |
| **单并发限制** | Whisper 推理会占用 CPU，多个同时请求会排队 | 当前单用户场景下不是问题 |
| **网络依赖** | edge-tts 需要访问微软服务器 | 服务器需有外网访问能力 |

### 9.2 风险项

| 风险 | 概率 | 缓解措施 |
|------|------|----------|
| Whisper 模型首次下载失败 | 中 | 可手动下载放到 `~/.cache/whisper/`，或预热 |
| edge-tts 微软服务不稳定 | 低 | 可降级为本地 TTS 或重试 |
| ESP32 发送的 Opus 格式不兼容 | 中 | ffmpeg 通常能处理，如不行需检查 ESP32 编码参数 |
| 长音频导致 Whisper OOM | 低 | base 模型在 4GB 环境处理 <30 秒音频没问题 |

---

## 10. 实机测试计划

### 10.1 前置准备

```bash
# 1. 确保服务器环境就绪
source venv/bin/activate
python -m pytest tests/ -v          # 所有测试通过
ffmpeg -version                      # 确认 ffmpeg 可用
python -c "import whisper; print(whisper.__version__)"   # 确认 whisper 可导入

# 2. 预热 Whisper 模型（避免首次调用等待下载）
python -c "import whisper; whisper.load_model('base')"

# 3. 确认 OpenClaw gateway 运行中
openclaw gateway status              # 或检查 :18789 端口

# 4. 启动 proxy
python main.py
```

### 10.2 模拟测试（无需 ESP32）

```bash
# 用 test_client.py 进行基本连通测试
python test_client.py
```

预期输出：
```
=== Connected ===

[TX] wake_word
[RX] {'type': 'state', 'status': 'listening'}

[TX] binary audio (80 bytes of mock Opus)
[RX] {'type': 'state', 'status': 'thinking'}
[RX] {'type': 'text_reply', 'text': '...（OpenClaw 回复）'}
[RX] binary audio: XXXX bytes

=== Done ===
```

> **注意：** `test_client.py` 发送的是 80 字节零填充 mock 数据，Whisper 可能识别为空或乱码。这是正常的——真实 Opus 音频才能产生有意义的识别结果。

### 10.3 ESP32 实机测试检查清单

- [ ] ESP32 小智固件已配置 WebSocket 地址为 `ws://<服务器IP>:8000/chat`
- [ ] 服务器防火墙已开放 8000 端口
- [ ] 按下按钮 → 服务器日志显示 `Hardware event: key_down`
- [ ] 对着麦克风说中文 → 服务器日志显示 `STT: transcribed → <识别的文字>`
- [ ] 松开按钮 → 服务器日志显示 `STT: received N bytes` 和 `LLM: sending to OpenClaw`
- [ ] ESP32 扬声器播放 TTS 合成的语音回复
- [ ] 多轮对话：连续提问，agent 能记住上下文（同一 session_id）

### 10.4 日志关键字速查

正常流程的日志序列：
```
STT: received XXXX bytes of Opus audio
Loading Whisper 'base' model…              ← 仅首次出现
STT: transcribed → 你好                    ← 识别结果
LLM: sending to OpenClaw → 你好
LLM: reply ← 你好！有什么我可以帮忙的？
TTS: synthesising → 你好！有什么我可以帮忙的？
TTS: generated XXXX bytes of Opus audio
Pushing XXXX bytes of audio to client
```

异常情况日志：
```
ffmpeg failed (rc=1): ...                  ← TTS ffmpeg 转码失败
OpenClaw CLI failed (rc=N): ...            ← LLM CLI 调用失败
```

---

## 11. 文件最终状态

### `main.py`（309 行）

```
第 1-14 行    — 导入（新增 tempfile, whisper, edge_tts）
第 16-22 行   — 日志配置
第 24-31 行   — 配置常量（新增 WHISPER_MODEL_SIZE, EDGE_TTS_VOICE, FFMPEG_BIN）
第 34-63 行   — Pydantic 模型（未变）
第 66-77 行   — Whisper 模型懒加载（新增）
第 80-102 行  — process_stt()（真实 Whisper 实现）
第 105-144 行 — call_openclaw()（未变）
第 147-187 行 — process_tts()（真实 edge-tts + ffmpeg 实现）
第 190-261 行 — 帧路由 + 管线编排（未变）
第 264-309 行 — FastAPI 应用 + 入口点（未变）
```

### `tests/test_main.py`（212 行）

```
11 个测试用例：
  - 1 个 STT 单元测试（重写）
  - 3 个 LLM 单元测试（未变）
  - 2 个 TTS 单元测试（1 个重写 + 1 个新增）
  - 5 个集成测试（1 个修改 mock 策略，4 个未变）
```

### `requirements.txt`（9 行）

```
新增 2 个依赖：openai-whisper, edge-tts
```

---

## 12. 与 PLAN.md 阶段映射

| PLAN.md 阶段 | 状态 | 本次完成内容 |
|-------------|------|-------------|
| Phase 0 — Mock 骨架 | ✅ 已完成 | — |
| Phase 0.5 — OpenClaw 对接 | ✅ 已完成 | — |
| Phase 1 — 音频缓冲 | ⏳ 待做 | — |
| **Phase 2 — 真实 STT** | **✅ 本次完成** | **Whisper base 模型接入** |
| Phase 3 — OpenClaw 对接 | ✅ 已在 0.5 完成 | — |
| **Phase 4 — 真实 TTS** | **✅ 本次完成** | **edge-tts + ffmpeg Opus 编码** |
| Phase 5 — 生产加固 | ⏳ 待做 | — |

**下一步：** Phase 1（音频缓冲），支持 ESP32 的 press-to-talk 交互模式——用户按住按键持续说话，松开后将完整音频合并送入管线。

---

## 13. 快速验证命令

```bash
# 进入虚拟环境
source venv/bin/activate

# 跑单元测试（应全部通过）
python -m pytest tests/ -v

# 启动服务（另一个终端）
python main.py

# 模拟客户端测试
python test_client.py
```


Now update PLAN.md to reflect the completed phases.


Edit

### Phase 2 — 真实 STT 接入

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**任务：**
- [ ] 选型确认（FunASR / SenseVoice / Whisper API 三选一）
- [ ] 实现 Opus → WAV 转码
  - 方案 A：`ffmpeg-python` 子进程管道（推荐，无需编译 C 扩展）
  - 方案 B：`pydub` + `ffmpeg` 系统命令
- [ ] 实现真实 STT 调用，返回识别文本
- [ ] 处理空音频 / 识别失败的降级逻辑
- [ ] 补充测试（mock 外部服务，验证转码和调用逻辑）

**依赖：** 服务器需安装 `ffmpeg` 系统包。

### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。

**交付物：**
- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）
- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）
- [x] 固定 `language="zh"` 中文识别
- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip


The file /root/projects/openclaw-proxy/PLAN.md has been updated successfully.

### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。

**交付物：**
- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）
- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）
- [x] 固定 `language="zh"` 中文识别
- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip

# PLAN.md — openclaw-proxy

## 项目定位

轻量级异步 WebSocket 网关，部署在 Linux 云服务器上，负责在 **小智 ESP32 语音硬件** 与 **OpenClaw 智能体** (`/root/.openclaw/`) 之间做协议翻译和管线编排。

```
┌──────────────┐  Opus/JSON   ┌──────────────────┐   CLI    ┌──────────────┐
│  XiaoZhi     │◄────────────►│  openclaw-proxy  │────────►│  OpenClaw    │
│  ESP32       │  WebSocket   │  (FastAPI:8000)   │  stdout  │  Gateway     │
└──────────────┘              └──────────────────┘◄────────│  (:18789)    │
                                  │        ▲                └──────────────┘
                                  ▼        │
                              ┌──────┐ ┌──────┐
                              │ STT  │ │ TTS  │
                              └──────┘ └──────┘
```

**对接方式决策（2026-03-15）：** 采用方案 B — 通过 `openclaw agent` CLI 调用 gateway，
不修改 `.openclaw/` 目录，不注册 channel 插件。优先跑通演示。

## 协议约定

### 上行（ESP32 → Proxy）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "hardware_event", "action": "wake_word\|key_down\|key_up"}` | 硬件状态事件 |
| 二进制帧 | Raw Opus bytes | 用户语音音频流 |

### 下行（Proxy → ESP32）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "state", "status": "listening\|thinking\|speaking"}` | 控制硬件 LED 状态 |
| 文本帧 | `{"type": "text_reply", "text": "..."}` | LLM 文本回复 |
| 二进制帧 | Raw Opus bytes | TTS 合成音频流 |

## 分阶段实施计划

---

### Phase 0 — Mock 骨架搭建 ✅ 已完成

**目标：** 跑通 WebSocket 握手和完整数据通路，用假数据验证时序正确。

**交付物：**
- [x] `main.py` — FastAPI + WS `/chat` 端点，帧路由，三段 mock 管线
- [x] `tests/test_main.py` — 8 个测试全部通过（3 单元 + 5 集成）
- [x] `requirements.txt` — 依赖声明

---

### Phase 0.5 — OpenClaw 真实对接 ✅ 已完成

**目标：** 将 `call_openclaw` 从 mock 替换为真实 CLI 调用，跑通文本对话。

**决策：** 采用 `openclaw agent --agent main --session-id {id} -m {text} --json` 命令。
Gateway 运行在 `localhost:18789`，CLI 自动连接。

**交付物：**
- [x] `call_openclaw` 通过 `asyncio.create_subprocess_exec` 调用 CLI
- [x] 每个 WebSocket 连接分配独立 `session_id`，支持多轮对话
- [x] LLM 推理期间每 3 秒发送 thinking 心跳，保持硬件活跃
- [x] CLI 失败 / 空 payload 的错误处理
- [x] `tests/test_main.py` — 10 个测试全部通过（mock subprocess）

**管线流程：**
```
Binary frame → process_stt(mock) → send "thinking" state
            → call_openclaw(CLI) → heartbeat every 3s → send text_reply
            → process_tts(mock) → send binary audio
```

---

### Phase 1 — 音频缓冲与真实 ESP32 握手

**目标：** 支持 ESP32 的真实交互模式——用户按住按键持续说话，松开后触发管线。

**任务：**
- [ ] 实现 `AudioBuffer` 类，按连接维护音频缓冲区
  - `key_down` / `wake_word` → 开始缓冲，进入 "listening" 状态
  - 后续二进制帧 → 追加到缓冲区
  - `key_up` → 停止缓冲，将完整音频交给管线
- [ ] 重构 `handle_binary_frame` 为缓冲追加逻辑
- [ ] 新增 `handle_pipeline(ws, audio_buffer)` 统一编排三段管线
- [ ] 处理边界情况：空音频、超长音频、重复 key_down
- [ ] 补充对应测试用例

**关键数据结构：**
```python
class SessionState(BaseModel):
    status: Literal["idle", "listening", "processing"] = "idle"
    audio_chunks: list[bytes] = []
```

---

### Phase 2 — 真实 STT 接入

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**任务：**
- [ ] 选型确认（FunASR / SenseVoice / Whisper API 三选一）
- [ ] 实现 Opus → WAV 转码
  - 方案 A：`ffmpeg-python` 子进程管道（推荐，无需编译 C 扩展）
  - 方案 B：`pydub` + `ffmpeg` 系统命令
- [ ] 实现真实 STT 调用，返回识别文本
- [ ] 处理空音频 / 识别失败的降级逻辑
- [ ] 补充测试（mock 外部服务，验证转码和调用逻辑）

**依赖：** 服务器需安装 `ffmpeg` 系统包。

---

### Phase 3 — ~~OpenClaw 智能体对接~~ ✅ 已在 Phase 0.5 完成

已通过 `openclaw agent` CLI 完成对接，无需额外工作。
后续可考虑升级为 channel 插件获得更紧密集成。

---

### Phase 4 — 真实 TTS 接入

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**任务：**
- [ ] 选型确认（edge-tts / CosyVoice / GPT-SoVITS 三选一）
- [ ] 实现文本 → WAV → Opus 合成管线
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知
- [ ] 补充测试

**分块推流时序：**
```
send state("speaking")
for chunk in opus_chunks:
    send binary(chunk)
    await asyncio.sleep(frame_duration)
send state("idle")
```

---

### Phase 5 — 生产加固

**目标：** 具备长期稳定运行的能力。

**任务：**
- [ ] 连接管理：最大并发数限制、心跳超时断开、异常重连
- [ ] 配置外置：`config.yaml` 或环境变量管理端口、模型选择、API 密钥等
- [ ] 结构化日志：JSON 格式日志，接入日志收集
- [ ] 指标监控：`/metrics` 端点暴露连接数、管线延迟、错误率
- [ ] systemd 服务单元文件，支持开机自启和自动重启
- [ ] HTTPS/WSS 支持（通过 nginx 反代或 uvicorn SSL 参数）
- [ ] HTTP 管理接口：`GET /sessions` 查看在线连接、`POST /broadcast` 广播消息

---

## 技术栈总览

| 层 | 当前（Phase 0） | 目标 |
|----|-----------------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | mock | FunASR / SenseVoice / Whisper |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | mock (静音帧) | edge-tts / CosyVoice |
| 音频编解码 | 无 | ffmpeg-python |
| 部署 | `python main.py` | systemd + nginx |

## 文件结构规划

```
openclaw-proxy/
├── main.py              # FastAPI 应用入口、WS 端点、帧路由
├── pipeline/
│   ├── __init__.py
│   ├── stt.py           # process_stt 实现
│   ├── llm.py           # call_openclaw 实现
│   └── tts.py           # process_tts 实现
├── models.py            # Pydantic 模型集中定义
├── session.py           # 连接会话状态管理、音频缓冲
├── config.py            # 配置加载（环境变量 / yaml）
├── tests/
│   ├── test_main.py
│   ├── test_stt.py
│   ├── test_llm.py
│   └── test_tts.py
├── requirements.txt
├── CLAUDE.md
└── PLAN.md
```

> 注：Phase 0 阶段全部逻辑集中在 `main.py`，从 Phase 1 开始按上述结构拆分模块。

+### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）

-- [ ] 选型确认（FunASR / SenseVoice / Whisper API 三选一）

-  - 方案 A：`ffmpeg-python` 子进程管道（推荐，无需编译 C 扩展）

-- [ ] 实现真实 STT 调用，返回识别文本

-- [ ] 处理空音频 / 识别失败的降级逻辑

-- [ ] 补充测试（mock 外部服务，验证转码和调用逻辑）

+**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。

-**依赖：** 服务器需安装 `ffmpeg` 系统包。

+- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）

+- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）

+- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip


### Phase 4 — 真实 TTS 接入

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**任务：**
- [ ] 选型确认（edge-tts / CosyVoice / GPT-SoVITS 三选一）
- [ ] 实现文本 → WAV → Opus 合成管线
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知
- [ ] 补充测试

**分块推流时序：**
```
send state("speaking")
for chunk in opus_chunks:
    send binary(chunk)
    await asyncio.sleep(frame_duration)
send state("idle")
```

### Phase 4 — 真实 TTS 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**决策：** 选用 edge-tts（微软 Neural TTS），MP3 → ffmpeg → Opus 编码。

**交付物：**
- [x] edge-tts `Communicate.stream()` 异步收集 MP3 音频
- [x] ffmpeg 管道转码 MP3 → Opus（libopus, 24kbps, 16kHz, mono）
- [x] ffmpeg 失败时抛出 RuntimeError
- [x] 测试：mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和错误处理
- [x] 11 个测试全部通过

> **详细记录见：** `MILESTONE-STT-TTS.md`

**遗留（留给 Phase 5）：**
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知


### Phase 4 — 真实 TTS 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**决策：** 选用 edge-tts（微软 Neural TTS），MP3 → ffmpeg → Opus 编码。

**交付物：**
- [x] edge-tts `Communicate.stream()` 异步收集 MP3 音频
- [x] ffmpeg 管道转码 MP3 → Opus（libopus, 24kbps, 16kHz, mono）
- [x] ffmpeg 失败时抛出 RuntimeError
- [x] 测试：mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和错误处理
- [x] 11 个测试全部通过

> **详细记录见：** `MILESTONE-STT-TTS.md`

**遗留（留给 Phase 5）：**
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知

# PLAN.md — openclaw-proxy

## 项目定位

轻量级异步 WebSocket 网关，部署在 Linux 云服务器上，负责在 **小智 ESP32 语音硬件** 与 **OpenClaw 智能体** (`/root/.openclaw/`) 之间做协议翻译和管线编排。

```
┌──────────────┐  Opus/JSON   ┌──────────────────┐   CLI    ┌──────────────┐
│  XiaoZhi     │◄────────────►│  openclaw-proxy  │────────►│  OpenClaw    │
│  ESP32       │  WebSocket   │  (FastAPI:8000)   │  stdout  │  Gateway     │
└──────────────┘              └──────────────────┘◄────────│  (:18789)    │
                                  │        ▲                └──────────────┘
                                  ▼        │
                              ┌──────┐ ┌──────┐
                              │ STT  │ │ TTS  │
                              └──────┘ └──────┘
```

**对接方式决策（2026-03-15）：** 采用方案 B — 通过 `openclaw agent` CLI 调用 gateway，
不修改 `.openclaw/` 目录，不注册 channel 插件。优先跑通演示。

## 协议约定

### 上行（ESP32 → Proxy）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "hardware_event", "action": "wake_word\|key_down\|key_up"}` | 硬件状态事件 |
| 二进制帧 | Raw Opus bytes | 用户语音音频流 |

### 下行（Proxy → ESP32）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "state", "status": "listening\|thinking\|speaking"}` | 控制硬件 LED 状态 |
| 文本帧 | `{"type": "text_reply", "text": "..."}` | LLM 文本回复 |
| 二进制帧 | Raw Opus bytes | TTS 合成音频流 |

## 分阶段实施计划

---

### Phase 0 — Mock 骨架搭建 ✅ 已完成

**目标：** 跑通 WebSocket 握手和完整数据通路，用假数据验证时序正确。

**交付物：**
- [x] `main.py` — FastAPI + WS `/chat` 端点，帧路由，三段 mock 管线
- [x] `tests/test_main.py` — 8 个测试全部通过（3 单元 + 5 集成）
- [x] `requirements.txt` — 依赖声明

---

### Phase 0.5 — OpenClaw 真实对接 ✅ 已完成

**目标：** 将 `call_openclaw` 从 mock 替换为真实 CLI 调用，跑通文本对话。

**决策：** 采用 `openclaw agent --agent main --session-id {id} -m {text} --json` 命令。
Gateway 运行在 `localhost:18789`，CLI 自动连接。

**交付物：**
- [x] `call_openclaw` 通过 `asyncio.create_subprocess_exec` 调用 CLI
- [x] 每个 WebSocket 连接分配独立 `session_id`，支持多轮对话
- [x] LLM 推理期间每 3 秒发送 thinking 心跳，保持硬件活跃
- [x] CLI 失败 / 空 payload 的错误处理
- [x] `tests/test_main.py` — 10 个测试全部通过（mock subprocess）

**管线流程：**
```
Binary frame → process_stt(mock) → send "thinking" state
            → call_openclaw(CLI) → heartbeat every 3s → send text_reply
            → process_tts(mock) → send binary audio
```

---

### Phase 1 — 音频缓冲与真实 ESP32 握手

**目标：** 支持 ESP32 的真实交互模式——用户按住按键持续说话，松开后触发管线。

**任务：**
- [ ] 实现 `AudioBuffer` 类，按连接维护音频缓冲区
  - `key_down` / `wake_word` → 开始缓冲，进入 "listening" 状态
  - 后续二进制帧 → 追加到缓冲区
  - `key_up` → 停止缓冲，将完整音频交给管线
- [ ] 重构 `handle_binary_frame` 为缓冲追加逻辑
- [ ] 新增 `handle_pipeline(ws, audio_buffer)` 统一编排三段管线
- [ ] 处理边界情况：空音频、超长音频、重复 key_down
- [ ] 补充对应测试用例

**关键数据结构：**
```python
class SessionState(BaseModel):
    status: Literal["idle", "listening", "processing"] = "idle"
    audio_chunks: list[bytes] = []
```

---

### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。

**交付物：**
- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）
- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）
- [x] 固定 `language="zh"` 中文识别
- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip

---

### Phase 3 — ~~OpenClaw 智能体对接~~ ✅ 已在 Phase 0.5 完成

已通过 `openclaw agent` CLI 完成对接，无需额外工作。
后续可考虑升级为 channel 插件获得更紧密集成。

---

### Phase 4 — 真实 TTS 接入

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**任务：**
- [ ] 选型确认（edge-tts / CosyVoice / GPT-SoVITS 三选一）
- [ ] 实现文本 → WAV → Opus 合成管线
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知
- [ ] 补充测试

**分块推流时序：**
```
send state("speaking")
for chunk in opus_chunks:
    send binary(chunk)
    await asyncio.sleep(frame_duration)
send state("idle")
```

---

### Phase 5 — 生产加固

**目标：** 具备长期稳定运行的能力。

**任务：**
- [ ] 连接管理：最大并发数限制、心跳超时断开、异常重连
- [ ] 配置外置：`config.yaml` 或环境变量管理端口、模型选择、API 密钥等
- [ ] 结构化日志：JSON 格式日志，接入日志收集
- [ ] 指标监控：`/metrics` 端点暴露连接数、管线延迟、错误率
- [ ] systemd 服务单元文件，支持开机自启和自动重启
- [ ] HTTPS/WSS 支持（通过 nginx 反代或 uvicorn SSL 参数）
- [ ] HTTP 管理接口：`GET /sessions` 查看在线连接、`POST /broadcast` 广播消息

---

## 技术栈总览

| 层 | 当前（Phase 0） | 目标 |
|----|-----------------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | mock | FunASR / SenseVoice / Whisper |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | mock (静音帧) | edge-tts / CosyVoice |
| 音频编解码 | 无 | ffmpeg-python |
| 部署 | `python main.py` | systemd + nginx |

## 文件结构规划

```
openclaw-proxy/
├── main.py              # FastAPI 应用入口、WS 端点、帧路由
├── pipeline/
│   ├── __init__.py
│   ├── stt.py           # process_stt 实现
│   ├── llm.py           # call_openclaw 实现
│   └── tts.py           # process_tts 实现
├── models.py            # Pydantic 模型集中定义
├── session.py           # 连接会话状态管理、音频缓冲
├── config.py            # 配置加载（环境变量 / yaml）
├── tests/
│   ├── test_main.py
│   ├── test_stt.py
│   ├── test_llm.py
│   └── test_tts.py
├── requirements.txt
├── CLAUDE.md
└── PLAN.md
```

> 注：Phase 0 阶段全部逻辑集中在 `main.py`，从 Phase 1 开始按上述结构拆分模块。

+### Phase 4 — 真实 TTS 接入 ✅ 已完成（2026-03-16）

-- [ ] 选型确认（edge-tts / CosyVoice / GPT-SoVITS 三选一）

+**决策：** 选用 edge-tts（微软 Neural TTS），MP3 → ffmpeg → Opus 编码。

+- [x] edge-tts `Communicate.stream()` 异步收集 MP3 音频

+- [x] ffmpeg 管道转码 MP3 → Opus（libopus, 24kbps, 16kHz, mono）

+- [x] 测试：mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和错误处理

+- [x] 11 个测试全部通过

- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发

-**分块推流时序：**

-    await asyncio.sleep(frame_duration)


Update the tech stack table in PLAN.md:


| 层 | 当前（Phase 0） | 目标 |
|----|-----------------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | mock | FunASR / SenseVoice / Whisper |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | mock (静音帧) | edge-tts / CosyVoice |
| 音频编解码 | 无 | ffmpeg-python |
| 部署 | `python main.py` | systemd + nginx |

| 层 | 当前 | 目标 |
|----|------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | Whisper base (本地 CPU) | 不变（可选升级 small/medium） |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | edge-tts → ffmpeg → Opus | 不变（可选 CosyVoice） |
| 音频编解码 | ffmpeg (libopus) | 不变 |
| 部署 | `python main.py` | systemd + nginx |


| 层 | 当前 | 目标 |
|----|------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | Whisper base (本地 CPU) | 不变（可选升级 small/medium） |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | edge-tts → ffmpeg → Opus | 不变（可选 CosyVoice） |
| 音频编解码 | ffmpeg (libopus) | 不变 |
| 部署 | `python main.py` | systemd + nginx |

# PLAN.md — openclaw-proxy

## 项目定位

轻量级异步 WebSocket 网关，部署在 Linux 云服务器上，负责在 **小智 ESP32 语音硬件** 与 **OpenClaw 智能体** (`/root/.openclaw/`) 之间做协议翻译和管线编排。

```
┌──────────────┐  Opus/JSON   ┌──────────────────┐   CLI    ┌──────────────┐
│  XiaoZhi     │◄────────────►│  openclaw-proxy  │────────►│  OpenClaw    │
│  ESP32       │  WebSocket   │  (FastAPI:8000)   │  stdout  │  Gateway     │
└──────────────┘              └──────────────────┘◄────────│  (:18789)    │
                                  │        ▲                └──────────────┘
                                  ▼        │
                              ┌──────┐ ┌──────┐
                              │ STT  │ │ TTS  │
                              └──────┘ └──────┘
```

**对接方式决策（2026-03-15）：** 采用方案 B — 通过 `openclaw agent` CLI 调用 gateway，
不修改 `.openclaw/` 目录，不注册 channel 插件。优先跑通演示。

## 协议约定

### 上行（ESP32 → Proxy）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "hardware_event", "action": "wake_word\|key_down\|key_up"}` | 硬件状态事件 |
| 二进制帧 | Raw Opus bytes | 用户语音音频流 |

### 下行（Proxy → ESP32）

| 帧类型 | 格式 | 说明 |
|--------|------|------|
| 文本帧 | `{"type": "state", "status": "listening\|thinking\|speaking"}` | 控制硬件 LED 状态 |
| 文本帧 | `{"type": "text_reply", "text": "..."}` | LLM 文本回复 |
| 二进制帧 | Raw Opus bytes | TTS 合成音频流 |

## 分阶段实施计划

---

### Phase 0 — Mock 骨架搭建 ✅ 已完成

**目标：** 跑通 WebSocket 握手和完整数据通路，用假数据验证时序正确。

**交付物：**
- [x] `main.py` — FastAPI + WS `/chat` 端点，帧路由，三段 mock 管线
- [x] `tests/test_main.py` — 8 个测试全部通过（3 单元 + 5 集成）
- [x] `requirements.txt` — 依赖声明

---

### Phase 0.5 — OpenClaw 真实对接 ✅ 已完成

**目标：** 将 `call_openclaw` 从 mock 替换为真实 CLI 调用，跑通文本对话。

**决策：** 采用 `openclaw agent --agent main --session-id {id} -m {text} --json` 命令。
Gateway 运行在 `localhost:18789`，CLI 自动连接。

**交付物：**
- [x] `call_openclaw` 通过 `asyncio.create_subprocess_exec` 调用 CLI
- [x] 每个 WebSocket 连接分配独立 `session_id`，支持多轮对话
- [x] LLM 推理期间每 3 秒发送 thinking 心跳，保持硬件活跃
- [x] CLI 失败 / 空 payload 的错误处理
- [x] `tests/test_main.py` — 10 个测试全部通过（mock subprocess）

**管线流程：**
```
Binary frame → process_stt(mock) → send "thinking" state
            → call_openclaw(CLI) → heartbeat every 3s → send text_reply
            → process_tts(mock) → send binary audio
```

---

### Phase 1 — 音频缓冲与真实 ESP32 握手

**目标：** 支持 ESP32 的真实交互模式——用户按住按键持续说话，松开后触发管线。

**任务：**
- [ ] 实现 `AudioBuffer` 类，按连接维护音频缓冲区
  - `key_down` / `wake_word` → 开始缓冲，进入 "listening" 状态
  - 后续二进制帧 → 追加到缓冲区
  - `key_up` → 停止缓冲，将完整音频交给管线
- [ ] 重构 `handle_binary_frame` 为缓冲追加逻辑
- [ ] 新增 `handle_pipeline(ws, audio_buffer)` 统一编排三段管线
- [ ] 处理边界情况：空音频、超长音频、重复 key_down
- [ ] 补充对应测试用例

**关键数据结构：**
```python
class SessionState(BaseModel):
    status: Literal["idle", "listening", "processing"] = "idle"
    audio_chunks: list[bytes] = []
```

---

### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。

**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。

**交付物：**
- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）
- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）
- [x] 固定 `language="zh"` 中文识别
- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip

---

### Phase 3 — ~~OpenClaw 智能体对接~~ ✅ 已在 Phase 0.5 完成

已通过 `openclaw agent` CLI 完成对接，无需额外工作。
后续可考虑升级为 channel 插件获得更紧密集成。

---

### Phase 4 — 真实 TTS 接入 ✅ 已完成（2026-03-16）

**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。

**决策：** 选用 edge-tts（微软 Neural TTS），MP3 → ffmpeg → Opus 编码。

**交付物：**
- [x] edge-tts `Communicate.stream()` 异步收集 MP3 音频
- [x] ffmpeg 管道转码 MP3 → Opus（libopus, 24kbps, 16kHz, mono）
- [x] ffmpeg 失败时抛出 RuntimeError
- [x] 测试：mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和错误处理
- [x] 11 个测试全部通过

> **详细记录见：** `MILESTONE-STT-TTS.md`

**遗留（留给 Phase 5）：**
- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知

---

### Phase 5 — 生产加固

**目标：** 具备长期稳定运行的能力。

**任务：**
- [ ] 连接管理：最大并发数限制、心跳超时断开、异常重连
- [ ] 配置外置：`config.yaml` 或环境变量管理端口、模型选择、API 密钥等
- [ ] 结构化日志：JSON 格式日志，接入日志收集
- [ ] 指标监控：`/metrics` 端点暴露连接数、管线延迟、错误率
- [ ] systemd 服务单元文件，支持开机自启和自动重启
- [ ] HTTPS/WSS 支持（通过 nginx 反代或 uvicorn SSL 参数）
- [ ] HTTP 管理接口：`GET /sessions` 查看在线连接、`POST /broadcast` 广播消息

---

## 技术栈总览

| 层 | 当前（Phase 0） | 目标 |
|----|-----------------|------|
| 框架 | FastAPI + uvicorn | 不变 |
| WS 协议 | Starlette WebSocket | 不变 |
| 数据校验 | Pydantic v2 | 不变 |
| STT | mock | FunASR / SenseVoice / Whisper |
| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
| TTS | mock (静音帧) | edge-tts / CosyVoice |
| 音频编解码 | 无 | ffmpeg-python |
| 部署 | `python main.py` | systemd + nginx |

## 文件结构规划

```
openclaw-proxy/
├── main.py              # FastAPI 应用入口、WS 端点、帧路由
├── pipeline/
│   ├── __init__.py
│   ├── stt.py           # process_stt 实现
│   ├── llm.py           # call_openclaw 实现
│   └── tts.py           # process_tts 实现
├── models.py            # Pydantic 模型集中定义
├── session.py           # 连接会话状态管理、音频缓冲
├── config.py            # 配置加载（环境变量 / yaml）
├── tests/
│   ├── test_main.py
│   ├── test_stt.py
│   ├── test_llm.py
│   └── test_tts.py
├── requirements.txt
├── CLAUDE.md
└── PLAN.md
```

> 注：Phase 0 阶段全部逻辑集中在 `main.py`，从 Phase 1 开始按上述结构拆分模块。

+| 层 | 当前 | 目标 |

-| STT | mock | FunASR / SenseVoice / Whisper |

+| STT | Whisper base (本地 CPU) | 不变（可选升级 small/medium） |

-| TTS | mock (静音帧) | edge-tts / CosyVoice |

+| TTS | edge-tts → ffmpeg → Opus | 不变（可选 CosyVoice） |


Done. Here's a summary of what was created:

**`MILESTONE-STT-TTS.md`** — 详细的 milestone 文档，包含 13 个章节：

1. **概述** — 变更前后对比
2. **技术选型与理由** — 为什么选 Whisper base 和 edge-tts，以及不选其他方案的原因
3. **架构变更** — 数据流图、STT/TTS 链路详细说明、关键设计决策（lazy loading、asyncio.to_thread、管道传输）
4. **代码变更详情** — 三个文件的逐行变更明细，含 diff
5. **测试结果** — 11 passed 完整输出
6. **依赖清单** — 运行时依赖 + 系统依赖 + 模型文件大小
7. **配置参数** — 所有可调参数及说明
8. **性能预估** — 各阶段延迟 + 内存占用估算
9. **已知限制与风险** — 4 个限制项 + 4 个风险项及缓解措施
10. **实机测试计划** — 前置准备、模拟测试、ESP32 实机检查清单、日志关键字速查
11. **文件最终状态** — 各文件行号范围索引
12. **与 PLAN.md 阶段映射** — Phase 2 + Phase 4 已完成
13. **快速验证命令**

同时更新了 **`PLAN.md`**，将 Phase 2 和 Phase 4 标记为 ✅ 已完成，更新了技术栈表。


还有哪些任务没完成


The user is asking what tasks remain to be done. Let me check the PLAN.md to see which phases are still pending.


根据 PLAN.md，已完成和待做的阶段如下：

| 阶段 | 状态 |
|------|------|
| Phase 0 — Mock 骨架 | ✅ |
| Phase 0.5 — OpenClaw CLI 对接 | ✅ |
| Phase 1 — 音频缓冲（press-to-talk） | ⏳ 待做 |
| Phase 2 — 真实 STT | ✅ |
| Phase 3 — OpenClaw 对接 | ✅（0.5 已完成） |
| Phase 4 — 真实 TTS | ✅ |
| Phase 5 — 生产加固 | ⏳ 待做 |

**具体待做内容：**

**Phase 1 — 音频缓冲：**
- `AudioBuffer` 类：`key_down`/`wake_word` 开始缓冲，二进制帧追加，`key_up` 触发管线
- 重构 `handle_binary_frame` 为缓冲追加逻辑
- 边界处理：空音频、超长音频、重复 key_down

**Phase 4 遗留：**
- TTS 分块推流（≤960 字节 Opus 帧逐帧下发）
- 推送 `speaking` 状态通知

**Phase 5 — 生产加固：**
- 连接管理（最大并发、心跳超时、异常重连）
- 配置外置（`config.yaml` / 环境变量）
- 结构化 JSON 日志
- `/metrics` 监控端点
- systemd 服务单元
- HTTPS/WSS（nginx 反代）
- HTTP 管理接口（`/sessions`, `/broadcast`）

明天实机测试后，**Phase 1（音频缓冲）** 是最关键的下一步——没有它，ESP32 的 press-to-talk 模式无法正常工作（当前每个音频片段会独立触发管线，而不是合并后再处理）。


Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.

In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
2. Identify similar features and architectural approaches
3. Consider multiple approaches and their trade-offs
4. Use AskUserQuestion if you need to clarify the approach
5. Design a concrete implementation strategy
6. When ready, use ExitPlanMode to present your plan for approval

Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase.


Agent

Explore audio buffering needs

Prompt:
Explore the openclaw-proxy codebase thoroughly to understand what's needed for Phase 1 (Audio Buffering / press-to-talk). Specifically:

1. Read main.py completely - understand the current frame routing in handle_text_frame and handle_binary_frame, and the websocket_chat endpoint. Focus on how session state is managed.

2. Read PLAN.md - specifically Phase 1 requirements for AudioBuffer, SessionState, and the press-to-talk interaction model.

3. Read tests/test_main.py - understand existing test patterns, especially the WebSocket integration tests and how binary frames are tested.

4. Understand the current flow: what happens when a wake_word or key_down event arrives vs when binary audio arrives? How does handle_binary_frame currently process each binary frame independently?

5. Look for any existing state management patterns per WebSocket connection.

Return ALL relevant code sections, line numbers, and the exact Phase 1 requirements from PLAN.md.


Explore the openclaw-proxy codebase thoroughly to understand what's needed for Phase 1 (Audio Buffering / press-to-talk). Specifically:

1. Read main.py completely - understand the current frame routing in handle_text_frame and handle_binary_frame, and the websocket_chat endpoint. Focus on how session state is managed.

2. Read PLAN.md - specifically Phase 1 requirements for AudioBuffer, SessionState, and the press-to-talk interaction model.

3. Read tests/test_main.py - understand existing test patterns, especially the WebSocket integration tests and how binary frames are tested.

4. Understand the current flow: what happens when a wake_word or key_down event arrives vs when binary audio arrives? How does handle_binary_frame currently process each binary frame independently?

5. Look for any existing state management patterns per WebSocket connection.

Return ALL relevant code sections, line numbers, and the exact Phase 1 requirements from PLAN.md.

Prompt:
Explore the openclaw-proxy codebase thoroughly to understand what's needed for Phase 1 (Audio Buffering / press-to-talk). Specifically:

1. Read main.py completely - understand the current frame routing in handle_text_frame and handle_binary_frame, and the websocket_chat endpoint. Focus on how session state is managed.

2. Read PLAN.md - specifically Phase 1 requirements for AudioBuffer, SessionState, and the press-to-talk interaction model.

3. Read tests/test_main.py - understand existing test patterns, especially the WebSocket integration tests and how binary frames are tested.

4. Understand the current flow: what happens when a wake_word or key_down event arrives vs when binary audio arrives? How does handle_binary_frame currently process each binary frame independently?

5. Look for any existing state management patterns per WebSocket connection.

Return ALL relevant code sections, line numbers, and the exact Phase 1 requirements from PLAN.md.


Error: EISDIR: illegal operation on a directory, read '/root/projects/openclaw-proxy'


     1→# PLAN.md — openclaw-proxy
     2→
     3→## 项目定位
     4→
     5→轻量级异步 WebSocket 网关，部署在 Linux 云服务器上，负责在 **小智 ESP32 语音硬件** 与 **OpenClaw 智能体** (`/root/.openclaw/`) 之间做协议翻译和管线编排。
     6→
     7→```
     8→┌──────────────┐  Opus/JSON   ┌──────────────────┐   CLI    ┌──────────────┐
     9→│  XiaoZhi     │◄────────────►│  openclaw-proxy  │────────►│  OpenClaw    │
    10→│  ESP32       │  WebSocket   │  (FastAPI:8000)   │  stdout  │  Gateway     │
    11→└──────────────┘              └──────────────────┘◄────────│  (:18789)    │
    12→                                  │        ▲                └──────────────┘
    13→                                  ▼        │
    14→                              ┌──────┐ ┌──────┐
    15→                              │ STT  │ │ TTS  │
    16→                              └──────┘ └──────┘
    17→```
    18→
    19→**对接方式决策（2026-03-15）：** 采用方案 B — 通过 `openclaw agent` CLI 调用 gateway，
    20→不修改 `.openclaw/` 目录，不注册 channel 插件。优先跑通演示。
    21→
    22→## 协议约定
    23→
    24→### 上行（ESP32 → Proxy）
    25→
    26→| 帧类型 | 格式 | 说明 |
    27→|--------|------|------|
    28→| 文本帧 | `{"type": "hardware_event", "action": "wake_word\|key_down\|key_up"}` | 硬件状态事件 |
    29→| 二进制帧 | Raw Opus bytes | 用户语音音频流 |
    30→
    31→### 下行（Proxy → ESP32）
    32→
    33→| 帧类型 | 格式 | 说明 |
    34→|--------|------|------|
    35→| 文本帧 | `{"type": "state", "status": "listening\|thinking\|speaking"}` | 控制硬件 LED 状态 |
    36→| 文本帧 | `{"type": "text_reply", "text": "..."}` | LLM 文本回复 |
    37→| 二进制帧 | Raw Opus bytes | TTS 合成音频流 |
    38→
    39→## 分阶段实施计划
    40→
    41→---
    42→
    43→### Phase 0 — Mock 骨架搭建 ✅ 已完成
    44→
    45→**目标：** 跑通 WebSocket 握手和完整数据通路，用假数据验证时序正确。
    46→
    47→**交付物：**
    48→- [x] `main.py` — FastAPI + WS `/chat` 端点，帧路由，三段 mock 管线
    49→- [x] `tests/test_main.py` — 8 个测试全部通过（3 单元 + 5 集成）
    50→- [x] `requirements.txt` — 依赖声明
    51→
    52→---
    53→
    54→### Phase 0.5 — OpenClaw 真实对接 ✅ 已完成
    55→
    56→**目标：** 将 `call_openclaw` 从 mock 替换为真实 CLI 调用，跑通文本对话。
    57→
    58→**决策：** 采用 `openclaw agent --agent main --session-id {id} -m {text} --json` 命令。
    59→Gateway 运行在 `localhost:18789`，CLI 自动连接。
    60→
    61→**交付物：**
    62→- [x] `call_openclaw` 通过 `asyncio.create_subprocess_exec` 调用 CLI
    63→- [x] 每个 WebSocket 连接分配独立 `session_id`，支持多轮对话
    64→- [x] LLM 推理期间每 3 秒发送 thinking 心跳，保持硬件活跃
    65→- [x] CLI 失败 / 空 payload 的错误处理
    66→- [x] `tests/test_main.py` — 10 个测试全部通过（mock subprocess）
    67→
    68→**管线流程：**
    69→```
    70→Binary frame → process_stt(mock) → send "thinking" state
    71→            → call_openclaw(CLI) → heartbeat every 3s → send text_reply
    72→            → process_tts(mock) → send binary audio
    73→```
    74→
    75→---
    76→
    77→### Phase 1 — 音频缓冲与真实 ESP32 握手
    78→
    79→**目标：** 支持 ESP32 的真实交互模式——用户按住按键持续说话，松开后触发管线。
    80→
    81→**任务：**
    82→- [ ] 实现 `AudioBuffer` 类，按连接维护音频缓冲区
    83→  - `key_down` / `wake_word` → 开始缓冲，进入 "listening" 状态
    84→  - 后续二进制帧 → 追加到缓冲区
    85→  - `key_up` → 停止缓冲，将完整音频交给管线
    86→- [ ] 重构 `handle_binary_frame` 为缓冲追加逻辑
    87→- [ ] 新增 `handle_pipeline(ws, audio_buffer)` 统一编排三段管线
    88→- [ ] 处理边界情况：空音频、超长音频、重复 key_down
    89→- [ ] 补充对应测试用例
    90→
    91→**关键数据结构：**
    92→```python
    93→class SessionState(BaseModel):
    94→    status: Literal["idle", "listening", "processing"] = "idle"
    95→    audio_chunks: list[bytes] = []
    96→```
    97→
    98→---
    99→
   100→### Phase 2 — 真实 STT 接入 ✅ 已完成（2026-03-16）
   101→
   102→**目标：** 将 `process_stt` 从 mock 替换为真实语音识别。
   103→
   104→**决策：** 选用 OpenAI Whisper base 模型，本地 CPU 推理。
   105→
   106→**交付物：**
   107→- [x] Whisper base 模型懒加载（`_get_whisper_model` 单例）
   108→- [x] Opus → tmpfile → Whisper transcribe（`asyncio.to_thread` 非阻塞）
   109→- [x] 固定 `language="zh"` 中文识别
   110→- [x] 测试：mock Whisper 模型验证临时文件创建、语言参数、文本 strip
   111→
   112→---
   113→
   114→### Phase 3 — ~~OpenClaw 智能体对接~~ ✅ 已在 Phase 0.5 完成
   115→
   116→已通过 `openclaw agent` CLI 完成对接，无需额外工作。
   117→后续可考虑升级为 channel 插件获得更紧密集成。
   118→
   119→---
   120→
   121→### Phase 4 — 真实 TTS 接入 ✅ 已完成（2026-03-16）
   122→
   123→**目标：** 将 `process_tts` 从 mock 替换为真实语音合成。
   124→
   125→**决策：** 选用 edge-tts（微软 Neural TTS），MP3 → ffmpeg → Opus 编码。
   126→
   127→**交付物：**
   128→- [x] edge-tts `Communicate.stream()` 异步收集 MP3 音频
   129→- [x] ffmpeg 管道转码 MP3 → Opus（libopus, 24kbps, 16kHz, mono）
   130→- [x] ffmpeg 失败时抛出 RuntimeError
   131→- [x] 测试：mock edge-tts stream + ffmpeg subprocess，验证 MP3 拼接和错误处理
   132→- [x] 11 个测试全部通过
   133→
   134→> **详细记录见：** `MILESTONE-STT-TTS.md`
   135→
   136→**遗留（留给 Phase 5）：**
   137→- [ ] 实现分块推流：将长音频切成 ≤960 字节 Opus 帧逐帧下发
   138→- [ ] 推送 `{"type": "state", "status": "speaking"}` 状态通知
   139→
   140→---
   141→
   142→### Phase 5 — 生产加固
   143→
   144→**目标：** 具备长期稳定运行的能力。
   145→
   146→**任务：**
   147→- [ ] 连接管理：最大并发数限制、心跳超时断开、异常重连
   148→- [ ] 配置外置：`config.yaml` 或环境变量管理端口、模型选择、API 密钥等
   149→- [ ] 结构化日志：JSON 格式日志，接入日志收集
   150→- [ ] 指标监控：`/metrics` 端点暴露连接数、管线延迟、错误率
   151→- [ ] systemd 服务单元文件，支持开机自启和自动重启
   152→- [ ] HTTPS/WSS 支持（通过 nginx 反代或 uvicorn SSL 参数）
   153→- [ ] HTTP 管理接口：`GET /sessions` 查看在线连接、`POST /broadcast` 广播消息
   154→
   155→---
   156→
   157→## 技术栈总览
   158→
   159→| 层 | 当前 | 目标 |
   160→|----|------|------|
   161→| 框架 | FastAPI + uvicorn | 不变 |
   162→| WS 协议 | Starlette WebSocket | 不变 |
   163→| 数据校验 | Pydantic v2 | 不变 |
   164→| STT | Whisper base (本地 CPU) | 不变（可选升级 small/medium） |
   165→| LLM | `openclaw agent` CLI → Gateway :18789 | 同左（可选升级为 channel 插件） |
   166→| TTS | edge-tts → ffmpeg → Opus | 不变（可选 CosyVoice） |
   167→| 音频编解码 | ffmpeg (libopus) | 不变 |
   168→| 部署 | `python main.py` | systemd + nginx |
   169→
   170→## 文件结构规划
   171→
   172→```
   173→openclaw-proxy/
   174→├── main.py              # FastAPI 应用入口、WS 端点、帧路由
   175→├── pipeline/
   176→│   ├── __init__.py
   177→│   ├── stt.py           # process_stt 实现
   178→│   ├── llm.py           # call_openclaw 实现
   179→│   └── tts.py           # process_tts 实现
   180→├── models.py            # Pydantic 模型集中定义
   181→├── session.py           # 连接会话状态管理、音频缓冲
   182→├── config.py            # 配置加载（环境变量 / yaml）
   183→├── tests/
   184→│   ├── test_main.py
   185→│   ├── test_stt.py
   186→│   ├── test_llm.py
   187→│   └── test_tts.py
   188→├── requirements.txt
   189→├── CLAUDE.md
   190→└── PLAN.md
   191→```
   192→
   193→> 注：Phase 0 阶段全部逻辑集中在 `main.py`，从 Phase 1 开始按上述结构拆分模块。
   194→


     1→"""openclaw-proxy — async WebSocket gateway between XiaoZhi ESP32 and OpenClaw agent."""
     2→
     3→import asyncio
     4→import json
     5→import logging
     6→import tempfile
     7→import uuid
     8→from enum import Enum
     9→from shutil import which
    10→
    11→import edge_tts
    12→import whisper
    13→from fastapi import FastAPI, WebSocket, WebSocketDisconnect
    14→from pydantic import BaseModel, ValidationError
    15→
    16→# ── Logging ──────────────────────────────────────────────────────────
    17→
    18→logging.basicConfig(
    19→    level=logging.INFO,
    20→    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    21→)
    22→logger = logging.getLogger("openclaw-proxy")
    23→
    24→# ── Configuration ────────────────────────────────────────────────────
    25→
    26→OPENCLAW_CLI = which("openclaw") or "openclaw"
    27→OPENCLAW_AGENT_ID = "main"
    28→OPENCLAW_TIMEOUT = 120  # seconds
    29→WHISPER_MODEL_SIZE = "base"
    30→EDGE_TTS_VOICE = "zh-CN-XiaoxiaoNeural"
    31→FFMPEG_BIN = which("ffmpeg") or "ffmpeg"
    32→
    33→
    34→# ── Pydantic models ─────────────────────────────────────────────────
    35→
    36→
    37→class HardwareAction(str, Enum):
    38→    """Actions reported by XiaoZhi ESP32 hardware."""
    39→
    40→    KEY_DOWN = "key_down"
    41→    KEY_UP = "key_up"
    42→    WAKE_WORD = "wake_word"
    43→
    44→
    45→class HardwareEvent(BaseModel):
    46→    """Incoming JSON frame from hardware."""
    47→
    48→    type: str
    49→    action: HardwareAction
    50→
    51→
    52→class StateNotification(BaseModel):
    53→    """Outgoing state frame pushed to hardware (e.g. breathing LED trigger)."""
    54→
    55→    type: str = "state"
    56→    status: str
    57→
    58→
    59→class TextReply(BaseModel):
    60→    """Outgoing text reply carrying the LLM response."""
    61→
    62→    type: str = "text_reply"
    63→    text: str
    64→
    65→
    66→# ── Whisper model (lazy singleton) ─────────────────────────────────
    67→
    68→_whisper_model: whisper.Whisper | None = None
    69→
    70→
    71→def _get_whisper_model() -> whisper.Whisper:
    72→    """Return the Whisper model, loading it on first call."""
    73→    global _whisper_model
    74→    if _whisper_model is None:
    75→        logger.info("Loading Whisper '%s' model…", WHISPER_MODEL_SIZE)
    76→        _whisper_model = whisper.load_model(WHISPER_MODEL_SIZE)
    77→    return _whisper_model
    78→
    79→
    80→# ── STT / TTS pipeline ────────────────────────────────────────────
    81→
    82→
    83→async def process_stt(audio_bytes: bytes) -> str:
    84→    """Convert Opus audio to text via Whisper.
    85→
    86→    Args:
    87→        audio_bytes: Raw Opus-encoded audio from ESP32.
    88→
    89→    Returns:
    90→        Transcribed text string.
    91→    """
    92→    logger.info("STT: received %d bytes of Opus audio", len(audio_bytes))
    93→    model = _get_whisper_model()
    94→    with tempfile.NamedTemporaryFile(suffix=".opus", delete=True) as f:
    95→        f.write(audio_bytes)
    96→        f.flush()
    97→        result = await asyncio.to_thread(
    98→            model.transcribe, f.name, language="zh"
    99→        )
   100→    text = result["text"].strip()
   101→    logger.info("STT: transcribed → %s", text)
   102→    return text
   103→
   104→
   105→async def call_openclaw(text: str, session_id: str) -> str:
   106→    """Send text to OpenClaw agent via CLI and return its reply.
   107→
   108→    Args:
   109→        text: User utterance transcribed by STT.
   110→        session_id: Per-connection session ID for multi-turn conversation.
   111→
   112→    Returns:
   113→        Agent reply string.
   114→
   115→    Raises:
   116→        RuntimeError: If the CLI call fails or returns unexpected output.
   117→    """
   118→    logger.info("LLM: sending to OpenClaw → %s", text)
   119→
   120→    proc = await asyncio.create_subprocess_exec(
   121→        OPENCLAW_CLI, "agent",
   122→        "--agent", OPENCLAW_AGENT_ID,
   123→        "--session-id", session_id,
   124→        "-m", text,
   125→        "--json",
   126→        "--timeout", str(OPENCLAW_TIMEOUT),
   127→        stdout=asyncio.subprocess.PIPE,
   128→        stderr=asyncio.subprocess.PIPE,
   129→    )
   130→    stdout, stderr = await proc.communicate()
   131→
   132→    if proc.returncode != 0:
   133→        err_msg = stderr.decode(errors="replace").strip()
   134→        logger.error("OpenClaw CLI failed (rc=%d): %s", proc.returncode, err_msg)
   135→        raise RuntimeError(f"OpenClaw CLI error: {err_msg}")
   136→
   137→    data = json.loads(stdout.decode())
   138→    payloads = data.get("result", {}).get("payloads", [])
   139→    if not payloads:
   140→        raise RuntimeError("OpenClaw returned empty payloads")
   141→
   142→    reply = payloads[0].get("text", "")
   143→    logger.info("LLM: reply ← %s", reply)
   144→    return reply
   145→
   146→
   147→async def process_tts(text: str) -> bytes:
   148→    """Convert text to Opus audio via edge-tts and ffmpeg.
   149→
   150→    Args:
   151→        text: Text to synthesize.
   152→
   153→    Returns:
   154→        Opus-encoded audio bytes.
   155→
   156→    Raises:
   157→        RuntimeError: If ffmpeg conversion fails.
   158→    """
   159→    logger.info("TTS: synthesising → %s", text)
   160→
   161→    # 1. edge-tts → MP3
   162→    communicate = edge_tts.Communicate(text=text, voice=EDGE_TTS_VOICE)
   163→    mp3_chunks: list[bytes] = []
   164→    async for chunk in communicate.stream():
   165→        if chunk["type"] == "audio":
   166→            mp3_chunks.append(chunk["data"])
   167→    mp3_bytes = b"".join(mp3_chunks)
   168→
   169→    # 2. MP3 → Opus via ffmpeg
   170→    proc = await asyncio.create_subprocess_exec(
   171→        FFMPEG_BIN, "-i", "pipe:0",
   172→        "-c:a", "libopus", "-b:a", "24k",
   173→        "-ar", "16000", "-ac", "1",
   174→        "-f", "opus", "pipe:1",
   175→        stdin=asyncio.subprocess.PIPE,
   176→        stdout=asyncio.subprocess.PIPE,
   177→        stderr=asyncio.subprocess.PIPE,
   178→    )
   179→    opus_bytes, stderr = await proc.communicate(input=mp3_bytes)
   180→
   181→    if proc.returncode != 0:
   182→        err_msg = stderr.decode(errors="replace").strip()
   183→        logger.error("ffmpeg failed (rc=%d): %s", proc.returncode, err_msg)
   184→        raise RuntimeError(f"ffmpeg TTS conversion error: {err_msg}")
   185→
   186→    logger.info("TTS: generated %d bytes of Opus audio", len(opus_bytes))
   187→    return opus_bytes
   188→
   189→
   190→# ── Frame router ────────────────────────────────────────────────────
   191→
   192→
   193→async def handle_text_frame(ws: WebSocket, raw: str) -> None:
   194→    """Parse and route an incoming JSON text frame.
   195→
   196→    Args:
   197→        ws: Active WebSocket connection.
   198→        raw: Raw text payload received from client.
   199→    """
   200→    try:
   201→        payload = json.loads(raw)
   202→    except json.JSONDecodeError:
   203→        logger.warning("Malformed JSON (ignored): %.120s", raw)
   204→        return
   205→
   206→    try:
   207→        event = HardwareEvent(**payload)
   208→    except ValidationError as exc:
   209→        logger.warning("Invalid hardware event: %s", exc.errors())
   210→        return
   211→
   212→    logger.info("Hardware event: %s", event.action.value)
   213→
   214→    if event.action == HardwareAction.WAKE_WORD:
   215→        ack = StateNotification(status="listening")
   216→        await ws.send_text(ack.model_dump_json())
   217→
   218→
   219→async def handle_binary_frame(ws: WebSocket, data: bytes, session_id: str) -> None:
   220→    """Run the full STT → LLM → TTS pipeline on an audio binary frame.
   221→
   222→    Args:
   223→        ws: Active WebSocket connection.
   224→        data: Raw Opus audio bytes.
   225→        session_id: Per-connection session ID.
   226→
   227→    Response sequence pushed to client:
   228→        1. {"type": "state", "status": "thinking"}  — triggers breathing LED
   229→        2. {"type": "text_reply", "text": "..."}     — agent answer
   230→        3. <binary>                                   — Opus audio of the answer
   231→    """
   232→    # Stage 1 — STT
   233→    text = await process_stt(data)
   234→
   235→    # Notify hardware: entering thinking phase
   236→    await ws.send_text(StateNotification(status="thinking").model_dump_json())
   237→
   238→    # Stage 2 — LLM (with periodic thinking heartbeats)
   239→    async def heartbeat() -> None:
   240→        """Send thinking heartbeat every 3 seconds while LLM is working."""
   241→        while True:
   242→            await asyncio.sleep(3)
   243→            try:
   244→                await ws.send_text(
   245→                    StateNotification(status="thinking").model_dump_json()
   246→                )
   247→            except Exception:
   248→                break
   249→
   250→    heartbeat_task = asyncio.create_task(heartbeat())
   251→    try:
   252→        reply = await call_openclaw(text, session_id)
   253→    finally:
   254→        heartbeat_task.cancel()
   255→
   256→    await ws.send_text(TextReply(text=reply).model_dump_json())
   257→
   258→    # Stage 3 — TTS
   259→    audio = await process_tts(reply)
   260→    logger.info("Pushing %d bytes of audio to client", len(audio))
   261→    await ws.send_bytes(audio)
   262→
   263→
   264→# ── FastAPI application ─────────────────────────────────────────────
   265→
   266→app = FastAPI(title="openclaw-proxy", version="0.1.0")
   267→
   268→
   269→@app.websocket("/chat")
   270→async def websocket_chat(ws: WebSocket) -> None:
   271→    """Main WebSocket endpoint for XiaoZhi ESP32 hardware."""
   272→    await ws.accept()
   273→    peer = f"{ws.client.host}:{ws.client.port}" if ws.client else "unknown"
   274→    session_id = f"xiaozhi-{uuid.uuid4().hex[:8]}"
   275→    logger.info("Client connected: %s (session=%s)", peer, session_id)
   276→
   277→    try:
   278→        while True:
   279→            message = await ws.receive()
   280→
   281→            if message["type"] == "websocket.disconnect":
   282→                break
   283→
   284→            text_data = message.get("text")
   285→            byte_data = message.get("bytes")
   286→
   287→            if text_data is not None:
   288→                await handle_text_frame(ws, text_data)
   289→            elif byte_data is not None:
   290→                await handle_binary_frame(ws, byte_data, session_id)
   291→    except WebSocketDisconnect:
   292→        pass
   293→    finally:
   294→        logger.info("Client disconnected: %s (session=%s)", peer, session_id)
   295→
   296→
   297→@app.get("/health")
   298→async def health() -> dict[str, str]:
   299→    """Liveness probe."""
   300→    return {"status": "ok"}
   301→
   302→
   303→# ── Entry point ─────────────────────────────────────────────────────
   304→
   305→if __name__ == "__main__":
   306→    import uvicorn
   307→
   308→    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
   309→


     1→"""Tests for openclaw-proxy WebSocket middleware."""
     2→
     3→import json
     4→from unittest.mock import AsyncMock, MagicMock, patch
     5→
     6→import pytest
     7→from starlette.testclient import TestClient
     8→
     9→from main import app, process_stt, call_openclaw, process_tts
    10→
    11→
    12→# ── Helpers ──────────────────────────────────────────────────────────
    13→
    14→MOCK_OPENCLAW_OUTPUT = json.dumps({
    15→    "status": "ok",
    16→    "result": {
    17→        "payloads": [{"text": "你好！我在这儿。", "mediaUrl": None}],
    18→    },
    19→}).encode()
    20→
    21→
    22→def _make_mock_process(
    23→    stdout: bytes = MOCK_OPENCLAW_OUTPUT,
    24→    returncode: int = 0,
    25→) -> AsyncMock:
    26→    """Build a mock asyncio.Process with preset stdout/stderr."""
    27→    proc = AsyncMock()
    28→    proc.communicate.return_value = (stdout, b"")
    29→    proc.returncode = returncode
    30→    return proc
    31→
    32→
    33→# ── Unit tests: pipeline functions ───────────────────────────────────
    34→
    35→
    36→@pytest.mark.asyncio
    37→async def test_process_stt_calls_whisper() -> None:
    38→    """STT should write audio to temp file, call Whisper, and return stripped text."""
    39→    mock_model = MagicMock()
    40→    mock_model.transcribe.return_value = {"text": " 你好世界 "}
    41→    with patch("main._get_whisper_model", return_value=mock_model):
    42→        result = await process_stt(b"\x00" * 80)
    43→    assert result == "你好世界"
    44→    mock_model.transcribe.assert_called_once()
    45→    args, kwargs = mock_model.transcribe.call_args
    46→    assert args[0].endswith(".opus")
    47→    assert kwargs == {"language": "zh"}
    48→
    49→
    50→@pytest.mark.asyncio
    51→async def test_call_openclaw_parses_cli_output() -> None:
    52→    """call_openclaw should parse the CLI JSON and return reply text."""
    53→    mock_proc = _make_mock_process()
    54→    with patch("main.asyncio.create_subprocess_exec", return_value=mock_proc):
    55→        result = await call_openclaw("你好", "test-session")
    56→    assert result == "你好！我在这儿。"
    57→
    58→
    59→@pytest.mark.asyncio
    60→async def test_call_openclaw_raises_on_cli_failure() -> None:
    61→    """call_openclaw should raise RuntimeError when CLI exits non-zero."""
    62→    mock_proc = _make_mock_process(stdout=b"", returncode=1)
    63→    mock_proc.communicate.return_value = (b"", b"some error")
    64→    with patch("main.asyncio.create_subprocess_exec", return_value=mock_proc):
    65→        with pytest.raises(RuntimeError, match="OpenClaw CLI error"):
    66→            await call_openclaw("你好", "test-session")
    67→
    68→
    69→@pytest.mark.asyncio
    70→async def test_call_openclaw_raises_on_empty_payloads() -> None:
    71→    """call_openclaw should raise RuntimeError on empty payloads."""
    72→    empty = json.dumps({"status": "ok", "result": {"payloads": []}}).encode()
    73→    mock_proc = _make_mock_process(stdout=empty)
    74→    with patch("main.asyncio.create_subprocess_exec", return_value=mock_proc):
    75→        with pytest.raises(RuntimeError, match="empty payloads"):
    76→            await call_openclaw("你好", "test-session")
    77→
    78→
    79→@pytest.mark.asyncio
    80→async def test_process_tts_returns_opus_bytes() -> None:
    81→    """TTS should collect MP3 from edge-tts, convert to Opus via ffmpeg."""
    82→
    83→    async def mock_stream() -> None:  # type: ignore[override]
    84→        yield {"type": "audio", "data": b"mp3-part1"}
    85→        yield {"type": "audio", "data": b"mp3-part2"}
    86→        yield {"type": "WordBoundary", "data": None}  # non-audio chunk
    87→
    88→    mock_comm = MagicMock()
    89→    mock_comm.stream = mock_stream
    90→
    91→    mock_proc = AsyncMock()
    92→    mock_proc.communicate.return_value = (b"fake-opus", b"")
    93→    mock_proc.returncode = 0
    94→
    95→    with (
    96→        patch("main.edge_tts.Communicate", return_value=mock_comm),
    97→        patch("main.asyncio.create_subprocess_exec", return_value=mock_proc),
    98→    ):
    99→        result = await process_tts("测试文本")
   100→
   101→    assert result == b"fake-opus"
   102→    mock_proc.communicate.assert_called_once_with(input=b"mp3-part1mp3-part2")
   103→
   104→
   105→@pytest.mark.asyncio
   106→async def test_process_tts_raises_on_ffmpeg_failure() -> None:
   107→    """TTS should raise RuntimeError when ffmpeg exits non-zero."""
   108→
   109→    async def mock_stream() -> None:  # type: ignore[override]
   110→        yield {"type": "audio", "data": b"mp3-data"}
   111→
   112→    mock_comm = MagicMock()
   113→    mock_comm.stream = mock_stream
   114→
   115→    mock_proc = AsyncMock()
   116→    mock_proc.communicate.return_value = (b"", b"ffmpeg error msg")
   117→    mock_proc.returncode = 1
   118→
   119→    with (
   120→        patch("main.edge_tts.Communicate", return_value=mock_comm),
   121→        patch("main.asyncio.create_subprocess_exec", return_value=mock_proc),
   122→    ):
   123→        with pytest.raises(RuntimeError, match="ffmpeg TTS conversion error"):
   124→            await process_tts("测试文本")
   125→
   126→
   127→# ── Integration tests: HTTP & WebSocket ──────────────────────────────
   128→
   129→
   130→def test_health_endpoint() -> None:
   131→    """GET /health should return 200 with ok status."""
   132→    client = TestClient(app)
   133→    resp = client.get("/health")
   134→    assert resp.status_code == 200
   135→    assert resp.json() == {"status": "ok"}
   136→
   137→
   138→def test_websocket_connect_and_disconnect() -> None:
   139→    """Client should be able to connect to /chat and cleanly disconnect."""
   140→    client = TestClient(app)
   141→    with client.websocket_connect("/chat"):
   142→        pass
   143→
   144→
   145→def test_websocket_text_frame_wake_word() -> None:
   146→    """Sending a wake_word JSON should get back a 'listening' state."""
   147→    client = TestClient(app)
   148→    with client.websocket_connect("/chat") as ws:
   149→        ws.send_text(json.dumps({
   150→            "type": "hardware_event",
   151→            "action": "wake_word",
   152→        }))
   153→        resp = json.loads(ws.receive_text())
   154→        assert resp["type"] == "state"
   155→        assert resp["status"] == "listening"
   156→
   157→
   158→def test_websocket_text_frame_malformed_json() -> None:
   159→    """Malformed JSON should not crash the connection."""
   160→    client = TestClient(app)
   161→    with client.websocket_connect("/chat") as ws:
   162→        ws.send_text("not-valid-json{{{")
   163→        ws.send_text(json.dumps({
   164→            "type": "hardware_event",
   165→            "action": "wake_word",
   166→        }))
   167→        resp = json.loads(ws.receive_text())
   168→        assert resp["status"] == "listening"
   169→
   170→
   171→def test_websocket_binary_frame_full_pipeline() -> None:
   172→    """Binary audio should trigger STT → LLM → TTS pipeline.
   173→
   174→    Expected response sequence:
   175→      1. {"type": "state", "status": "thinking"}
   176→      2. {"type": "text_reply", "text": "..."}
   177→      3. binary bytes (TTS audio)
   178→    """
   179→    client = TestClient(app)
   180→    with (
   181→        patch(
   182→            "main.process_stt",
   183→            new_callable=AsyncMock,
   184→            return_value="测试语音输入",
   185→        ),
   186→        patch(
   187→            "main.call_openclaw",
   188→            new_callable=AsyncMock,
   189→            return_value="你好！我在这儿。",
   190→        ),
   191→        patch(
   192→            "main.process_tts",
   193→            new_callable=AsyncMock,
   194→            return_value=b"\xfc\xff\xfe",
   195→        ),
   196→    ):
   197→        with client.websocket_connect("/chat") as ws:
   198→            ws.send_bytes(b"\x00" * 80)
   199→
   200→            # 1) thinking state
   201→            msg1 = json.loads(ws.receive_text())
   202→            assert msg1 == {"type": "state", "status": "thinking"}
   203→
   204→            # 2) text reply
   205→            msg2 = json.loads(ws.receive_text())
   206→            assert msg2["type"] == "text_reply"
   207→            assert msg2["text"] == "你好！我在这儿。"
   208→
   209→            # 3) TTS audio bytes
   210→            audio = ws.receive_bytes()
   211→            assert audio == b"\xfc\xff\xfe"
   212→


Bash

ls -la /root/projects/openclaw-proxy/ | grep -E "\.(py|md|txt)$"

List all Python, Markdown, and text files in project root


-rw-r--r-- 1 root root   1392 Mar 15 21:33 CLAUDE.md
-rw-r--r-- 1 root root   9972 Mar 16 00:14 main.py
-rw-r--r-- 1 root root  20939 Mar 16 00:27 MILESTONE-STT-TTS.md
-rw-r--r-- 1 root root   7663 Mar 16 00:28 PLAN.md
-rw-r--r-- 1 root root    162 Mar 16 00:14 requirements.txt
-rw-r--r-- 1 root root   1077 Mar 15 23:53 test_client.py