# Plan: Phase 1 — Audio Buffering (Press-to-Talk)

## Context

Phase 0/0.5/2/4 已完成：WebSocket 握手、OpenClaw CLI 对接、真实 STT (Whisper) 和 TTS (edge-tts) 均已跑通，11 个测试全部通过。

**当前问题：** 每个二进制帧独立触发完整 STT→LLM→TTS 管线。ESP32 按住按键说话时会发送多个 Opus 音频帧，当前每帧分别识别和回复，导致乱序错误。

**目标：** 实现 press-to-talk 状态机——`key_down`/`wake_word` 开始缓冲，二进制帧追加到缓冲区，`key_up` 合并音频后触发一次管线。

## 设计决策

| 问题 | 决策 |
|------|------|
| SessionState 存储位置 | `websocket_chat` 局部变量，传引用给 handler（最简单，无需清理） |
| wake_word vs key_down | 行为一致，均触发 idle→listening 转换 |
| 最大缓冲 | 10MB（`MAX_AUDIO_BUFFER_BYTES`），超限 drop 帧并 log warning |
| 空音频 key_up | Log warning，跳过管线，直接回 idle |
| idle 状态收到二进制帧 | Drop + log warning（严格状态机） |
| processing 状态收到二进制帧 | Drop + log warning |
| 管线异常 | `finally` 块保证 state 回 idle |

## 状态机

```
idle ──(key_down/wake_word)──► listening ──(key_up)──► processing ──(done/error)──► idle
         send "listening"       append audio            drain → STT→LLM→TTS
```

## 修改文件

### 1. `tests/test_main.py` — 先写失败测试（TDD）

**新增测试（8 个）：**

| 测试 | 类型 | 验证点 |
|------|------|--------|
| `test_session_state_defaults` | 单元 | status=="idle", audio_chunks==[] |
| `test_key_down_starts_listening` | 集成 | key_down → 返回 listening 状态 |
| `test_key_up_triggers_pipeline` | 集成 | key_down → binary → key_up → thinking + text_reply + audio |
| `test_key_up_empty_buffer_skips_pipeline` | 集成 | key_down → key_up（无音频）→ 不触发管线，连接存活 |
| `test_binary_frame_while_idle_is_dropped` | 集成 | idle 状态发 binary → 不触发管线，连接存活 |
| `test_binary_frames_are_concatenated` | 集成 | 3 个 binary 帧合并后传给 process_stt |
| `test_repeated_key_down_resets_buffer` | 集成 | key_down → binary → key_down → binary → key_up → 只有第二次的音频 |
| `test_key_up_without_key_down_is_ignored` | 集成 | idle 状态发 key_up → 不崩溃，连接存活 |

**修改 1 个现有测试：**
- `test_websocket_binary_frame_full_pipeline` → 改为 key_down → binary → key_up 序列

### 2. `main.py` — 实现变更

#### 2a. 新增导入

```python
from typing import Literal  # 第 1-14 行区域
```

#### 2b. 新增常量（Configuration 区域）

```python
MAX_AUDIO_BUFFER_BYTES: int = 10 * 1024 * 1024  # 10 MB
```

#### 2c. 新增 `SessionState` 模型（Pydantic models 区域后，约第 65 行后）

```python
class SessionState(BaseModel):
    status: Literal["idle", "listening", "processing"] = "idle"
    audio_chunks: list[bytes] = []

    def buffer_size(self) -> int:
        return sum(len(c) for c in self.audio_chunks)

    def drain_audio(self) -> bytes:
        audio = b"".join(self.audio_chunks)
        self.audio_chunks.clear()
        return audio

    def reset(self) -> None:
        self.status = "idle"
        self.audio_chunks.clear()
```

#### 2d. 重构 `handle_text_frame`

签名变更：`(ws, raw)` → `(ws, raw, state, session_id)`

新增逻辑：
- `KEY_DOWN` / `WAKE_WORD`：idle→listening（send "listening"）；listening 时 reset buffer 重新开始
- `KEY_UP`：listening→processing，调用 `handle_pipeline`；idle 时 ignore

#### 2e. 重构 `handle_binary_frame`

签名变更：`(ws, data, session_id)` → `(ws, data, state)`

新逻辑：仅在 listening 状态 append，其余状态 drop。检查 buffer 大小上限。
**不再调用 process_stt/call_openclaw/process_tts。**

#### 2f. 新增 `handle_pipeline(ws, state, session_id)`

从旧 `handle_binary_frame` 提取 STT→LLM→TTS 逻辑：
1. `audio = state.drain_audio()`
2. 空音频 → log + reset + return
3. `try:` 执行 STT → thinking → heartbeat + LLM → text_reply → TTS → binary
4. `finally: state.reset()` 保证回 idle

#### 2g. 更新 `websocket_chat`

```python
state = SessionState()  # 新增
# 传 state 给 handler：
await handle_text_frame(ws, text_data, state, session_id)
await handle_binary_frame(ws, byte_data, state)
```

### 3. `test_client.py` — 更新手动测试脚本

改为 key_down → binary → key_up 序列。

## 不变的部分

- `process_stt`, `call_openclaw`, `process_tts` — 内部不变
- Pydantic 消息模型（HardwareAction/Event, StateNotification, TextReply）— 不变
- `/health` 端点 — 不变
- 6 个管线单元测试 — 不变
- requirements.txt — 不变

## 验证

```bash
source venv/bin/activate && python -m pytest tests/ -v
# 预期：~19 个测试全部通过
```
