# 你的声音是怎么变成代码指令的？

解析 Claude Code 的"对讲机式"语音输入系统——从按住空格键录音、通过 WebSocket 连接 Deepgram STT 服务，到实时转录文字注入输入框的完整数据流。

> 🌍 **行业背景**：语音输入在 AI 编程工具中尚属小众功能。**GitHub Copilot** 和 **Cursor** 截至 2025 年均未提供内置语音输入——用户需要依赖系统级听写（macOS Dictation）或第三方工具（如 Whisper.cpp 的本地方案）。**Aider** 通过 `--voice` 参数支持语音输入，同样使用外部 STT 服务（OpenAI Whisper API），但采用"录完再发"模式而非实时流式转录。**Windsurf** 没有语音功能。在 AI 助手领域，ChatGPT 的语音模式和 Google Gemini Live 都使用 VAD（语音活动检测）自动判断用户说完——但它们运行在有回声消除的移动端/浏览器环境中。Claude Code 选择"对讲机"（hold-to-talk）模式是基于终端环境的务实判断：终端没有回声消除，且开发场景中键盘声、风扇声频繁。这不是技术落后，而是针对特定环境的正确取舍。

---

## 问题

你正在调试一个棘手的 bug，双手在键盘上飞舞。突然你想到一个思路，但打字太慢了——你按住空格键，对着麦克风说："帮我在 auth middleware 里找所有调用 verifyToken 的地方。"松手，文字就出现在输入框里。Claude Code 的语音模式是怎么工作的？为什么选择了"对讲机"而不是"语音助手"？

---

> **[图表预留 2.17-A]**：时序图 — 从按下空格键到文字出现在输入框的完整数据流（按键事件 → 录音启动 → WebSocket → Deepgram STT → interim/final 转录 → 注入输入框）

## 你可能以为……

"大概就是调用系统的语音识别 API 吧？像 macOS 的听写功能那样，按个快捷键就行了。"你可能这么想。毕竟操作系统自己就有语音转文字功能，何必重新发明轮子？

> 💡 **通俗理解**：语音模式就像**对讲机**而不是 Siri——你按住按钮说话，松手停止。为什么不用"说完自动停"的方式？因为终端环境没有回声消除，风扇声、键盘声都可能被误判为"你还在说话"。对讲机模式完美回避了这个问题：你按着它录，你松手它停，语义边界完全由你控制。

---

## 实际上是这样的

Voice 模式是一个从硬件录音到云端转录的完整管道——有原生音频捕获、WebSocket 实时流式传输、20 种语言支持、音量波形可视化，甚至还有一个针对后端 bug 的客户端自动重试机制。它选择"对讲机模式"而非"语音助手模式"，是基于终端环境特殊限制的务实设计。

### 小节 1：五道门才能开口说话

在你按下空格键之前，`/voice` 命令需要通过五道安全检查，每一道失败都有针对性的诊断提示：

```typescript
// src/commands/voice/voice.ts，第 16-148 行
// 五步 pre-flight 检查链：
1. isVoiceModeEnabled()    → 认证 + GrowthBook kill-switch
2. checkRecordingAvailability() → 录音硬件可用？
3. isVoiceStreamAvailable()    → API token 有效？
4. checkVoiceDependencies()    → SoX 已安装？（非 macOS）
5. requestMicrophonePermission() → 操作系统权限通过？
```

第一道门本身就是三层嵌套（`voiceModeEnabled.ts:16-23`）：

```
feature('VOICE_MODE')                              ← 编译时 flag
  && !getFeatureValue('tengu_amber_quartz_disabled') ← GrowthBook kill-switch
  && isAnthropicAuthEnabled()                        ← 必须是 Anthropic OAuth
```

注意那个 GrowthBook gate 的名字——`tengu_amber_quartz_disabled`。它是**反向设计**的：默认值是 `false`（即语音默认启用），只在紧急情况下翻转为 `true` 来全局关闭语音功能。这意味着新安装的 CLI 不需要等待 GrowthBook 初始化就能使用语音。`amber_quartz`（琥珀石英）是语音模式的内部代号。

第五道门——麦克风权限——在 macOS 上会触发系统级 TCC 弹窗。为了避免用户在不使用语音时看到这个弹窗，原生音频模块是 **lazy-loaded** 的（`voice.ts`），只在用户明确启用 `/voice` 时才加载。

### 小节 2：为什么是对讲机，不是 Siri

这是整个语音系统最关键的设计决策。

Siri、Alexa 这类语音助手使用的是"说完自动停"模式——VAD（Voice Activity Detection）检测到你停止说话后，自动结束录音。但终端环境有一个致命问题：**没有回声消除**。

想象一下：你在终端里让 Claude 执行一个命令，终端开始滚动输出日志。如果用 VAD 模式，系统可能会把终端的蜂鸣声、风扇噪音、甚至你敲键盘的声音误判为"用户还在说话"而无法停止录音。反过来，如果你说话时停顿了一秒想词，VAD 可能会过早截断。

对讲机模式（hold-to-talk）干净利落地回避了这个问题——这也是 Discord、TeamSpeak 等语音通讯软件在嘈杂环境下的标准做法：**你按着，它录；你松手，它停。** 语义边界完全由人类控制。

但"按住空格"在终端里有一个技术挑战——**键盘自动重复（auto-repeat）**。当你按住一个键时，操作系统会以 30-80ms 的间隔持续发送 keydown 事件。怎么区分"用户还按着"和"用户已经松手"？

```typescript
// src/hooks/useVoice.ts
RELEASE_TIMEOUT_MS = 200   // 200ms 内没有新的 keydown → 判定为松手
REPEAT_FALLBACK_MS = 600   // 如果没检测到 auto-repeat，600ms 后启动 release timer
FIRST_PRESS_FALLBACK_MS = 2000  // 首次按键，最多等 2 秒（macOS 长延迟键重复）
```

200ms 的阈值经过精心校准：终端 auto-repeat 通常在 30-80ms 之间，200ms 足够宽松地判定"真的松手了"，同时又不会让用户感到明显延迟。`FIRST_PRESS_FALLBACK_MS` 设为 2 秒是因为 macOS 系统设置允许用户把键盘重复延迟调到极长值。

### 小节 3：从声波到文字的旅程（端到端感知延迟典型在数百毫秒量级）

按住空格后发生了什么？

```
你的声音 → 麦克风 → 16-bit signed PCM 采样
  → WebSocket 连接 → Anthropic voice_stream 端点
  → Deepgram STT 引擎
  → interim 转录（实时、不稳定）
  → final 转录（稳定、追加到输入框）
```

录音模块在不同平台上使用不同的后端：

- **macOS**：原生 `audio-capture-napi` 模块，直接调用 Core Audio API
- **其他平台**：SoX 命令行工具（`sox -d -t raw -r 16000 -e signed -b 16 -c 1 -`）

音频格式统一为 16-bit signed PCM、16kHz 采样率、单声道——这是语音识别的标准输入格式，足够清晰又不浪费带宽。

WebSocket 连接到 Anthropic 的 `voice_stream` 端点（底层走 claude.ai 的 conversation_engine 基础设施），使用 Deepgram 作为 STT 引擎。本章关于 "麦克风 → PCM → WebSocket → Deepgram" 的数据流描述部分是从注释和参数推断组合得来的——具体 WebSocket 建立与鉴权的源码入口未在"代码落点"列出，感兴趣的读者可以在 `src/voice/` 与 `src/hooks/useVoice.ts` 附近继续深挖。转录结果分两种：

- **interim**（中间结果）：实时更新，但可能被后续结果推翻。用于给用户即时反馈。
- **final**（最终结果）：稳定的转录片段，追加到输入框文本中。

当你松手时，系统调用 `finishRecording()`，关闭 WebSocket 连接，提交最终文本。

### 小节 4：让安静的声音"看起来"更大

> 📚 **课程关联**：这一节涉及**数字信号处理**（DSP）课程的核心概念——RMS（均方根）计算、归一化和非线性映射。平方根曲线本质上是一种**感知权重函数**，它模拟了人耳对声压的对数感知特性（韦伯-费希纳定律）。在音频工程中，dBFS（分贝满量程）标准本身就是对数尺度，这里的 sqrt 变换是简化版的同类思想。

录音时，输入框旁边会显示一个 16 格的音量条形图，让你知道麦克风在工作。但这里有一个视觉设计技巧：

```typescript
// src/hooks/useVoice.ts，第 179-197 行
const AUDIO_LEVEL_BARS = 16

export function computeLevel(chunk: Buffer): number {
  // 16-bit signed PCM → 计算 RMS（均方根）
  const rms = Math.sqrt(sumSq / samples)
  // 归一化到 0-1
  const normalized = Math.min(rms / 2000, 1)
  // sqrt 曲线拉伸
  return Math.sqrt(normalized)
}
```

关键是最后一行的 `Math.sqrt(normalized)`——**平方根曲线**。这是音频可视化的经典技巧：人说话时大部分时间是比较安静的（RMS 值集中在低端），如果直接用线性映射，音量条大部分时间都在底部几格晃悠，看起来像麦克风坏了。平方根曲线把低音量段的视觉范围拉伸了——RMS 只有 0.04（很安静）时，显示为 0.2（视觉上 20% 高度），让用户即使在轻声说话时也能看到明显的音量反馈。

### 小节 5：1% 的会话遇到的"幽灵 bug"

> 📚 **课程关联**：silent-drop replay 机制可以类比**分布式系统**课程中"客户端重试与幂等性"的实战案例。当 session-sticky 路由将请求粘在故障 pod 上时，客户端通过新建连接绕过粘性路由——"断路器"（circuit breaker）这个术语是本章作者的引申类比，并非源码注释原文里出现的说法，只做示意不做严格等同。2MB 音频缓冲的"重试缓冲区"设计，在消息队列（Kafka）和流处理系统中也有相似思想。

注：下方引文与数据流部分描述来自源码注释的直接摘录和合理推断组合。"replay on a fresh WS once"、"~1% of sessions"、"hadAudioSignal=true" 都是注释原文；"最大约 2MB = 32KB/s × 60 秒"则是依据 16-bit PCM / 16kHz / 单声道 的码率（约 32KB/s）与典型录音时长上限推算得到，用于建立量级直觉，不代表源码中存在明确的 2MB 常量。

这是整个语音系统最值得学习的工程细节。`useVoice.ts:243-247` 的注释记录了一个真实的后端 bug：

> "~1% of sessions get a sticky-broken CE pod that accepts audio but returns zero transcripts (anthropics/anthropic#287008 session-sticky variant); when finalize() resolves via no_data_timeout with hadAudioSignal=true, we replay the buffer on a fresh WS once."

翻译成人话：大约 1% 的会话会连接到一个"坏掉的"后端 pod——它正常接收你的音频数据，但就是不返回任何转录结果。这是一个 session-sticky（会话粘性）路由的变种 bug：一旦你被路由到坏 pod，之后的所有请求都会粘在那个 pod 上。

客户端的应对策略叫做 **silent-drop replay**：

1. 录音期间，系统始终保存完整的音频缓冲（最大约 2MB = 32KB/s × 60 秒）
2. 录音结束时，如果检测到"有音频信号但无转录结果"（`hadAudioSignal=true` 但 final 转录为空），判定为遇到了坏 pod
3. 在一个**全新的** WebSocket 连接上重放完整的音频缓冲
4. 新连接被路由到不同的 pod，正常返回转录

这是一个优雅的客户端容错机制——不需要等后端修 bug，不需要用户手动重试，除了多等几百毫秒之外几乎无感知。唯一的代价是 2MB 的内存缓冲和一次额外的 WebSocket 连接。

### 小节 6：20 种语言和"只提醒两次"

语音模式支持 20 种语言的 STT 转录：

```
en, es, fr, ja, de, pt, it, ko, hi, id,
ru, pl, tr, nl, uk, el, cs, da, sv, no
```

语言映射表（`useVoice.ts:42-89`）支持多种输入形式：英文名（`"french"`）、本地名（`"français"`）、简化名（`"francais"`）。语言来源是 `settings.language` 设置，如果设置的语言不在支持列表中，系统 fallback 到英语并提示用户。

但提示有一个次数限制：

```typescript
// src/commands/voice/voice.ts，第 14 行
const LANG_HINT_MAX_SHOWS = 2
```

语言不支持的提示**最多显示 2 次**。这是一个微妙的 UX 决策——如果你设置了中文但每次启动语音都被提醒"中文不支持，已切换到英语"，前两次是有用信息，之后就是噪音。

### 小节 7：Focus 模式——终端获得焦点就自动录音（基于引用名推断）

除了手动按键的标准模式，代码中还出现了 `focusTriggeredRef`、`useTerminalFocus` 等命名以及 `FOCUS_SILENCE_TIMEOUT_MS`，根据这些命名**推断**存在一个 Focus 模式：当终端窗口获得焦点时自动开始录音。需要明确：下文的"终端获得焦点就自动开始录音"属于依据命名反推的**叙述性假设**，本章未从源码中找到一段明确写"on focus → start recording"的语句，真正的入口逻辑可能更复杂（例如要求用户额外按某个快捷键、或只在特定 feature flag 下开启）。以下内容请按"可能的行为模型"阅读。

```typescript
// src/hooks/useVoice.ts，第 177 行
FOCUS_SILENCE_TIMEOUT_MS = 5000  // Focus 模式下 5 秒无语音则断开
```

5 秒的静默超时意味着：你切到终端窗口，如果 5 秒内没说话，系统自动断开——避免在你只是切窗口看一眼时持续占用 WebSocket 连接和后端资源。

---

## 这背后的哲学

Voice 模式的设计体现了**"知道什么不做"比"做什么"更重要**的工程哲学，同时也意味着对应的代价必须被承认：

1. **不做 VAD**。终端没有回声消除，VAD 在这个环境下不可靠。对讲机模式把复杂的音频信号处理问题简化为一个二值信号：按着=录，松手=停。**代价**：长语音不友好——给长时间说话的用户带来生理负担（一直按住空格键几分钟手指会累），而且一旦你在长语音中途手滑松开就会被判定结束、触发转录，无法简单地"暂停-继续"。这对快速一句话命令是最佳体验，对口述长段代码或讨论就不够理想。
2. **不做 TTS**。系统只支持语音转文字（STT），不支持文字转语音（TTS）。这不是技术限制——而是产品判断：开发者需要的是快速输入想法，不是听 AI 念代码。终端本身就是最好的文字输出界面。
3. **不做自研 STT**。直接使用 Deepgram（通过 Anthropic 的 voice_stream 端点代理），不重复造轮子。Anthropic 的核心竞争力是 LLM，不是语音识别。**代价**：非 macOS 平台需要用户**自行安装 SoX 命令行工具**（`brew install sox` / `apt install sox` 等）——如果用户系统没有 SoX，五道门里的第 4 道（`checkVoiceDependencies()`）会直接拦截并提示安装。这是一个外部依赖，不能自动捆绑（发行证一致性/二进制体积），发行在 Linux 上尤其增加了用户上手门槛。macOS 之所以不需要是因为走 napi 模块直连 Core Audio。
4. **不信任后端**。silent-drop replay 机制假设后端可能出错，客户端自己兜底。这在分布式系统设计中是一个重要原则：**永远为对方的失败做准备**。

反向 kill-switch 的设计尤其值得玩味——默认启用（`disabled=false`），而不是默认关闭等审批。这说明 Anthropic 的功能发布策略偏向"先开放，有问题再紧急关闭"，而不是"先关闭，确认没问题再开放"。对于一个非安全关键的 UX 功能来说，这是合理的风险偏好。

---

## 代码落点

- `src/voice/voiceModeEnabled.ts`，第 16-23 行：三层 gate（feature flag + GrowthBook + OAuth）
- `src/voice/voiceModeEnabled.ts`，第 21 行：`tengu_amber_quartz_disabled` kill-switch
- `src/commands/voice/voice.ts`，第 14 行：`LANG_HINT_MAX_SHOWS = 2`
- `src/commands/voice/voice.ts`，第 16-148 行：五步 pre-flight 检查链
- `src/hooks/useVoice.ts`，第 42-89 行：`LANGUAGE_NAME_TO_CODE` 20 种语言映射
- `src/hooks/useVoice.ts`，第 93-114 行：`SUPPORTED_LANGUAGE_CODES` 集合
- `src/hooks/useVoice.ts`，第 160 行：`RELEASE_TIMEOUT_MS = 200`
- `src/hooks/useVoice.ts`，第 171 行：`REPEAT_FALLBACK_MS = 600`
- `src/hooks/useVoice.ts`，第 172 行：`FIRST_PRESS_FALLBACK_MS = 2000`
- `src/hooks/useVoice.ts`，第 177 行：`FOCUS_SILENCE_TIMEOUT_MS = 5000`
- `src/hooks/useVoice.ts`，第 179-197 行：`computeLevel()` 音量可视化（sqrt 曲线）
- `src/hooks/useVoice.ts`，第 243-247 行：silent-drop replay 机制注释

---

## 还可以追问的方向

1. **Focus 模式的完整触发逻辑**：`useTerminalFocus` 是如何与窗口焦点事件绑定的？
2. **voice_stream 端点的认证细节**：WebSocket 连接使用什么 token？OAuth access token 还是专用的 voice token？
3. **多语言混合场景**：如果用户在一段话里混合中英文，Deepgram 会如何处理？
4. **录音格式选择的权衡**：为什么不用 Opus/WebM 压缩？16-bit PCM 在弱网环境下的带宽成本？
5. **macOS TCC 权限的用户体验**：如果用户在系统设置里拒绝了麦克风权限，重新启用的引导流程？

---

