# 语音系统完全解析

本章解析 Claude Code 的实验性语音输入子系统——如何通过"按住说话"将语音转换为文字，并无缝注入已有的文本输入管线。

---

> **🌍 行业背景**：语音输入正在成为 AI 编程工具的差异化竞争点。**GitHub Copilot Voice** 早期曾作为预览功能提供，基于 VS Code Speech 扩展实现语音到代码的转换。**Cursor** 在较新版本中通过 Whisper API 集成了语音输入，支持 Composer 模式下的语音交互。**Aider** 通过第三方插件 `aider-voice` 支持语音，但不是官方功能。**Codex（OpenAI）** 在本文撰写时未公开提供语音输入能力（上述对外部产品的判断基于公开资料，具体版本与状态可能已更新）。在 CLI 终端环境中实现语音输入的关键技术挑战是：终端不像浏览器那样有 Web Audio API，必须依赖外部录音工具（SoX）或平台原生模块，这也是 Claude Code 语音系统架构复杂度高于 GUI 编辑器方案的根本原因。

---

## 本章导读

语音系统是 Claude Code 2.1.88 中的一个实验性子系统，允许用户通过"按住说话"（hold-to-talk）的方式用语音输入代替键盘打字——语音转写后**注入输入框**，与键盘输入的文字无缝合并（不自动提交，提交仍由用户显式触发）。

**技术比喻（OS 视角）**：语音系统像操作系统中的**输入法框架**（如 macOS Input Method Kit / Linux IBus）——它拦截一种物理输入（语音），经过中间处理（STT 语音识别），转换为另一种输入（文字），最终注入到已有的文本输入管线中。整个过程对下游的对话系统完全透明。

> 💡 **通俗理解**：语音系统像**智能音箱（小爱同学 / Alexa）**——先唤醒（按住快捷键激活），再听你说话（STT 语音转文字），最后理解你的指令并执行（提交给 Claude 处理）。区别在于，Claude Code 的语音不是一直监听，而是需要你主动按住按键。

## 架构分布

与 Bridge 或 Buddy 不同，语音系统的代码没有集中在一个目录下，而是分散在多个位置：

| 文件 | 位置 | 职责 |
|------|------|------|
| `voiceModeEnabled.ts` | `src/voice/` | 特性门控与权限检查 |
| `voice.ts` | `src/commands/voice/` | `/voice` 命令实现（开关切换） |
| `voice.tsx` | `src/context/` | Voice 状态上下文（React Context） |
| `useVoice.ts` | `src/hooks/` | 核心录音 + STT 连接 Hook |
| `useVoiceEnabled.ts` | `src/hooks/` | 启用状态 Hook |
| `useVoiceIntegration.tsx` | `src/hooks/` | 按键检测 + 文本注入集成 Hook |
| `voiceStreamSTT.ts` | `src/services/` | Anthropic voice_stream 端点 STT 客户端 |
| `voiceKeyterms.ts` | `src/services/` | 语音关键词（代码术语纠正） |
| `VoiceIndicator.tsx` | `src/components/` | 语音录制状态指示器 |
| `VoiceModeNotice.tsx` | `src/components/` | 语音模式提示组件 |

## 1. 特性门控三重检查

语音系统的启用检查是所有子系统中最严格的之一，涉及三个独立的门控层。

### 1.1 编译时门控

`voiceModeEnabled.ts` 第 20-23 行：

```typescript
export function isVoiceGrowthBookEnabled(): boolean {
  return feature('VOICE_MODE')
    ? !getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)
    : false
}
```

`feature('VOICE_MODE')` 是 Bun 编译时常量——如果构建时未启用 VOICE_MODE 特性，这个函数会被死代码消除（DCE），整个语音系统的代码都不会进入最终产物。

GrowthBook 的 `tengu_amber_quartz_disabled` 是一个**反向开关**（kill-switch）——默认 false 表示"未禁用"，设为 true 则紧急关闭语音功能。注意 `tengu_amber_quartz` 是一个混淆名称，实际指代语音功能。

### 1.2 认证门控

```typescript
export function hasVoiceAuth(): boolean {
  if (!isAnthropicAuthEnabled()) return false
  const tokens = getClaudeAIOAuthTokens()
  return Boolean(tokens?.accessToken)
}
```

语音功能要求 Anthropic OAuth 认证——API Key、Bedrock、Vertex、Foundry 均不可用。原因是 STT 使用 claude.ai 的 `voice_stream` 端点，该端点仅对 OAuth 用户开放。

### 1.3 运行时完整检查

```typescript
export function isVoiceModeEnabled(): boolean {
  return hasVoiceAuth() && isVoiceGrowthBookEnabled()
}
```

三层检查的执行顺序：编译时（`feature('VOICE_MODE')`） → 运行时认证（OAuth） → 运行时远程开关（GrowthBook）。

## 2. /voice 命令实现

`src/commands/voice/voice.ts` 实现了 `/voice` 斜杠命令，它是一个**开关式命令**：

```typescript
export const call: LocalCommandCall = async () => {
  // 1. 总开关检查
  if (!isVoiceModeEnabled()) {
    if (!isAnthropicAuthEnabled()) {
      return { type: 'text', value: 'Voice mode requires a Claude.ai account.' }
    }
    return { type: 'text', value: 'Voice mode is not available.' }
  }

  const isCurrentlyEnabled = currentSettings.voiceEnabled === true

  // 2. 关闭分支（简单）
  if (isCurrentlyEnabled) {
    updateSettingsForSource('userSettings', { voiceEnabled: false })
    logEvent('tengu_voice_toggled', { enabled: false })
    return { type: 'text', value: 'Voice mode disabled.' }
  }

  // 3. 开启分支（需要预检）
  // 3a. 检查录音硬件可用性
  const recording = await checkRecordingAvailability()
  if (!recording.available) return { type: 'text', value: recording.reason }

  // 3b. 检查 voice_stream API 可用性
  if (!isVoiceStreamAvailable()) return { ... }

  // 3c. 检查录音工具（SoX）
  const deps = await checkVoiceDependencies()
  if (!deps.available) return { ... }

  // 3d. 预先请求麦克风权限
  if (!(await requestMicrophonePermission())) return { ... }

  // 4. 全部通过，启用
  updateSettingsForSource('userSettings', { voiceEnabled: true })
}
```

开启时的预检非常全面：录音硬件 → API → SoX 工具 → 麦克风权限，任何一环失败都给出精确的错误提示。

## 3. 语音状态管理

### 3.1 React Context Store

`src/context/voice.tsx` 使用自定义 Store 模式管理语音状态：

```typescript
export type VoiceState = {
  voiceState: 'idle' | 'recording' | 'processing'  // 三态机
  voiceError: string | null                         // 错误信息
  voiceInterimTranscript: string                    // 实时转写结果
  voiceAudioLevels: number[]                        // 音频电平（可视化）
  voiceWarmingUp: boolean                           // 预热中标志
}
```

三态机模型：
```
idle ──[按住按键]──> recording ──[松开按键]──> processing ──[收到结果]──> idle
  ^                                                 |
  |_____________[出错或超时]________________________|
```

### 3.2 外部同步存储

使用 React 18+ 标准的 `useSyncExternalStore` + selector 模式，让各消费组件只在所关心的状态片段变化时才重渲染：

```typescript
export function useVoiceState<T>(selector: (state: VoiceState) => T): T {
  const store = useVoiceStore()
  const get = () => selector(store.getState())
  return useSyncExternalStore(store.subscribe, get, get)
}
```

这是 React 生态中管理外部状态的标准做法（Zustand、Jotai 等库内部使用相同模式），此处不再展开。

## 4. 按键检测与录音集成

### 4.1 按住检测算法

`useVoiceIntegration.tsx` 实现了一套精细的按键检测逻辑，用于区分"按住"（hold）和"普通打字"（normal typing）。

关键常量：

```typescript
const RAPID_KEY_GAP_MS = 120;          // 自动重复阈值（终端自动重复 30-80ms）
const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000;  // 修饰键首次按压超时
const HOLD_THRESHOLD = 5;              // 裸字符键需要 5 次快速重复才激活
const WARMUP_THRESHOLD = 2;            // 2 次重复时开始显示预热反馈
```

检测策略分两种情况：

**修饰键组合**（如 Ctrl+Space）：首次按下就激活，因为修饰键组合不会是正常打字。

**裸字符键**（如 Space）：需要连续 5 次快速重复才激活，防止将正常打字误判为"按住"。

默认配置是裸空格键——注意所有修饰符均为 `false`，这意味着默认触发方式是**按住空格键**（终端产生自动重复键事件，累计到 5 次后激活），而非修饰键组合：

```typescript
const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = {
  key: ' ', ctrl: false, alt: false, shift: false, meta: false, super: false
}
```

用户可以在设置中将其改为修饰键组合（如 `ctrl: true`），此时切换到"首次按下即激活"的策略。

**WARMUP_THRESHOLD 的渐进反馈设计**值得特别关注。`WARMUP_THRESHOLD = 2` 意味着用户按住空格键、终端产生第 2 次自动重复事件时就会看到预热反馈（`voiceWarmingUp: true` 触发 UI 提示），而不需要等到第 5 次才有响应。这是一个**感知延迟优化**（perceived latency optimization）——用户在按到第 2 次时就知道"系统收到了，正在准备"，而不是毫无反馈地按到第 5 次。在终端环境中，由于缺乏浏览器那样丰富的视觉反馈手段（悬浮提示、动画过渡等），这种提前反馈对用户体验的影响尤为显著。

### 4.2 键盘事件匹配

`matchesKeyboardEvent` 函数（第 61-73 行）处理了终端键盘事件的各种陷阱：

```typescript
function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean {
  const key = e.key === 'space' ? ' ' 
            : e.key === 'return' ? 'enter' 
            : e.key.toLowerCase()
  if (key !== target.key) return false
  if (e.ctrl !== target.ctrl) return false
  if (e.shift !== target.shift) return false
  // KeyboardEvent.meta 在终端中将 alt/option 合并（esc 前缀限制）
  if (e.meta !== (target.alt || target.meta)) return false
  if (e.superKey !== target.super) return false
  return true
}
```

注释揭示了一个终端限制：`KeyboardEvent.meta` 无法区分 Alt 和 Meta 键（因为终端用 ESC 前缀表示这两个键），所以将它们视为同一个。

## 5. STT 语音识别

### 5.1 voice_stream 端点

语音识别使用 Anthropic 的 `voice_stream` 端点，通过 WebSocket 进行流式传输。

从客户端代码的 `voiceKeyterms.ts` 中可以看到，关键词列表以 Deepgram 的 `keywords` 参数格式（`term:boost` 权重对）传递给服务端，且 WebSocket 初始化消息的字段命名（如 `interim_results`、`keywords`）与 Deepgram Streaming API 高度吻合。这强烈暗示后端 STT 引擎使用了 Deepgram，但源码中未找到直接的 Deepgram 域名或 SDK 引用，因此这一判断属于**合理推断而非确证**。

### 5.2 STT 管线的完整数据流

从用户按住按键到文字出现在输入框中，整个 STT 管线经历以下阶段：

```
麦克风 → 音频采集（SoX/native module） → PCM 编码 → WebSocket 二进制帧 → voice_stream 端点
                                                                                    ↓
输入框 ← 文本注入 ← final transcript ← interim transcript ← STT 引擎识别 ← 服务端解码
```

**阶段 1：音频采集**。`useVoice` Hook 在 `recording` 状态下启动录音进程——macOS 使用原生音频模块直接调用 Core Audio API，Linux 上通过 SoX 命令行工具（`rec` 命令）采集。音频以 PCM 格式（线性脉冲编码调制）采样，这是未压缩的原始音频数据。

**阶段 2：WebSocket 流式传输**。`voiceStreamSTT.ts` 负责建立与 `voice_stream` 端点的 WebSocket 连接。连接初始化时发送配置消息，包含：
- `language`：用户选择的语言代码（BCP-47 格式）
- `keywords`：来自 `voiceKeyterms.ts` 的领域关键词列表及其权重
- `interim_results: true`：请求服务端返回中间识别结果

音频数据以二进制帧（Binary Frame）实时发送到服务端，而非等录音结束后一次性上传——这是流式 STT 相对于批量 STT 的关键区别，用户可以在说话过程中就看到文字逐渐出现。

**阶段 3：结果接收与状态更新**。服务端通过同一 WebSocket 连接返回 JSON 格式的识别结果。`voiceStreamSTT.ts` 解析这些消息，区分 interim 和 final 结果，并更新 VoiceState 中对应的字段。当用户松开按键后，客户端发送结束信号，服务端完成最后一段音频的处理并返回 final transcript。

**阶段 4：文本注入**。`useVoiceIntegration.tsx` 监听 VoiceState 变化，在收到 final transcript 后将文字注入到 `InputPrompt` 的文本框中，与键盘输入的文字无缝合并。

> 💡 **通俗理解**：这个流程就像**实时字幕**——你在看外语电影时，翻译员一边听一边打字幕（interim），最后校对确认（final）。区别在于 Claude Code 的"字幕"是你自己说的话，确认后变成给 Claude 的指令。

### 5.3 连接管理与错误恢复

`voiceStreamSTT.ts` 的 WebSocket 连接需要处理多种边界情况：

- **认证**：使用 OAuth access token 通过 WebSocket 握手阶段的 `Authorization` 头进行认证，token 过期时需要刷新后重连
- **连接中断**：网络波动导致 WebSocket 断开时，当前录音会话终止，状态机回到 `idle`，用户需要重新按住按键触发新的会话
- **语言不支持**：如果客户端发送了服务器不允许的语言代码，WebSocket 以 1008（Policy Violation）状态码关闭

这里没有自动重连机制——这是一个务实的设计选择：语音输入是短时间的交互（通常几秒到几十秒），与需要长期保持连接的聊天场景不同。如果连接断开，让用户重新按住按键开始新的会话，比在后台静默重连更符合 hold-to-talk 的交互预期。

### 5.4 多语言支持

`useVoice.ts` 第 42-89 行定义了语言名称到 BCP-47 代码的映射：

```typescript
const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
  english: 'en',
  spanish: 'es', español: 'es', espanol: 'es',
  french: 'fr', français: 'fr', francais: 'fr',
  japanese: 'ja', 日本語: 'ja',
  german: 'de', deutsch: 'de',
  korean: 'ko', 한국어: 'ko',
  hindi: 'hi', हिन्दी: 'hi',
  russian: 'ru', русский: 'ru',
  chinese: 'zh', 中文: 'zh',
  // ... 共 20 种语言（见下方 SUPPORTED_LANGUAGE_CODES，需为服务端 allowlist 子集）
}
```

每种语言同时支持英文名和原生名称（如 `français` / `francais` / `french` 都映射到 `fr`）。

关键约束在注释中：

```
// This list must be a SUBSET of the server-side supported_language_codes allowlist
// (GrowthBook: speech_to_text_voice_stream_config).
// If the CLI sends a code the server rejects, the WebSocket closes with
// 1008 "Unsupported language" and voice breaks.
```

如果客户端发送了服务器不支持的语言代码，WebSocket 会以 1008 状态码关闭——所以客户端必须保守地只使用已知支持的语言。

> **📚 课程关联**：流式 STT 的 interim/final 双结果模型更准确地类比为**乐观 UI 渲染**（Optimistic UI）——interim 结果立即展示给用户以消除感知延迟，而 final 结果到达时**完全替换**（而非合并收敛）interim 内容。这与分布式系统中的"最终一致性"不同：最终一致性强调多副本最终收敛到同一状态，而 STT 的 interim 结果会被 final 直接覆盖。

### 5.5 实时中间结果与音频电平

如 5.2 节所述，STT 返回两种结果：
- **interim transcript**：实时中间结果，显示在输入框中（用户可以看到文字在"长出来"），对应 `VoiceState` 中的 `voiceInterimTranscript` 字段
- **final transcript**：最终确认结果，完全替换 interim 内容后提交给 Claude

需要特别区分的是：`VoiceState` 中的 `voiceAudioLevels` 是**客户端本地采集**的音频电平数据，用于驱动 `VoiceIndicator` 组件的波形可视化动画。它与 STT 返回的 transcript 是两个完全独立的数据流——前者来自本地麦克风输入的实时振幅采样，后者来自服务端的语音识别结果。即使 WebSocket 连接延迟或中断，音频电平动画仍然会正常显示，因为它不依赖服务端响应。

## 6. 领域词汇纠正（voiceKeyterms）——语音编程的核心竞争力

在编程场景中，STT 误识别代码术语是**语音输入体验的头号杀手**。通用语音模型把 "git" 听成 "get"、"npm" 听成 "and PM"、"kubectl" 听成毫不相关的词——这些错误会让开发者很快放弃语音输入。`voiceKeyterms.ts` 正是解决这个问题的关键模块。

### 6.1 关键词列表的构成

`voiceKeyterms.ts` 维护了一份编程领域的高频术语列表，覆盖多个类别：

- **工具和运行时**：`npm`、`yarn`、`pnpm`、`bun`、`deno`、`webpack`、`vite`、`eslint`、`prettier`
- **版本控制**：`git`、`GitHub`、`GitLab`、`rebase`、`cherry-pick`、`stash`
- **语言和框架**：`TypeScript`、`JavaScript`、`React`、`Vue`、`Svelte`、`Next.js`、`Rust`、`Go`
- **Claude Code 自身术语**：`Claude`、`Anthropic`、`MCP`、`tool use`、`slash command`

### 6.2 关键词传递机制

客户端 `voiceKeyterms.ts` 的 `GLOBAL_KEYTERMS` 是**纯字符串数组**（无 boost 值），MAX_KEYTERMS 上限 50：

```typescript
const GLOBAL_KEYTERMS: readonly string[] = [
  'MCP', 'symlink', 'grep', 'regex', 'localhost',
  'codebase', 'TypeScript', 'JSON', 'OAuth', 'webhook',
  'gRPC', 'dotfiles', 'subagent', 'worktree',
]
```

关键词通过 WebSocket 初始化消息作为字符串列表传递给服务端 STT 引擎。Deepgram 的 `keywords` 参数原生支持 `term:boost` 的格式，但客户端并未在源头附加权重——是否做 boost、如何 boost，由服务端决定。注释明确指出 `Claude` 和 `Anthropic` 已是服务端自带的 base keyterms，客户端无需重复传递；同时避免传递"没人按字母发音"的术语（如 `stdout` → "standard out"）。

### 6.3 为什么这是核心 UX 差异化

对于普通语音助手，把 "git" 识别成 "get" 只是一个小瑕疵——上下文通常能让用户理解意思。但对于**编程语音输入**，每个术语都可能直接变成代码或命令：

- 用户口述 "git rebase main"，如果被识别为 "get re-base main"，注入到 Claude 的 prompt 中后会产生完全不同的理解
- 用户说 "用 pnpm 安装依赖"，如果 "pnpm" 被识别成 "p-n-p-m" 或无意义的词，Claude 可能无法正确执行

这使得领域词汇纠正从"锦上添花"变成了"必须可用"的基础能力。Claude Code 通过预置关键词列表解决了开箱即用的问题，但目前尚未提供用户自定义关键词的接口——对于使用小众工具链（如特定公司内部 CLI 工具）的团队来说，这是一个值得关注的扩展点。

## 7. 条件加载与死代码消除

`useVoiceIntegration.tsx` 第 21-33 行使用 `feature('VOICE_MODE') ? require(real) : { stub }` 模式实现条件加载：

```typescript
const voiceNs: {
  useVoice: typeof import('./useVoice.js').useVoice;
} = feature('VOICE_MODE')
  ? require('./useVoice.js')
  : {
      useVoice: ({ enabled: _e }) => ({
        state: 'idle' as const,
        handleKeyEvent: (_fallbackMs?: number) => {}
      })
    };
```

这是 feature flag + 条件 require 的标准模式，核心目的有二：

1. **React Hooks 规则合规**：React 要求 Hook 不能出现在条件分支中。通过桩函数，组件可以无条件调用 `useVoice`，避免违规。
2. **测试 spy 兼容**：捕获模块命名空间对象（`voiceNs`）而非解构导出函数，确保 `spyOn(voiceNs, 'useVoice')` 能正确拦截。这解决了 ESM live binding 环境下，直接解构导入会让 spy 失效的常见陷阱。

## 8. 录音工具链

语音录音依赖外部工具：

- **macOS**：使用原生音频模块（native audio module）
- **Linux/其他**：需要 SoX（Sound eXchange）工具

`/voice` 命令在启用时会检查这些依赖，如果缺失会提供安装指引：

```typescript
const deps = await checkVoiceDependencies()
if (!deps.available) {
  const hint = deps.installCommand
    ? `\nInstall audio recording tools? Run: ${deps.installCommand}`
    : '\nInstall SoX manually for audio recording.'
  return { type: 'text', value: `No audio recording tool found.${hint}` }
}
```

## 批判性分析

### 优点

1. **渐进式启用**：编译时门控 → 认证门控 → 远程开关 → 硬件检查 → 工具检查 → 权限检查，六层防护确保用户不会遇到"功能开了但不能用"的尴尬
2. **精确的按键检测**：区分修饰键组合（立即激活）和裸字符键（需要按住），避免误触，这在终端环境中非常不容易做好
3. **条件加载**：通过编译时 feature flag + 运行时 require/noop 桩的组合，在未启用时实现零开销，同时保持 React Hooks 规则合规
4. **多语言支持**：同时接受英文名和本地化名称（如 `日本語`、`русский`），降低了配置门槛

### 不足

1. **代码分散**：语音相关代码分布在 6 个目录的 10+ 个文件中，缺乏一个统一的入口点或架构文档，新开发者理解起来困难
2. **混淆命名**：`tengu_amber_quartz_disabled` 这种混淆名称虽然防止了外部猜测特性含义，但也让内部代码可读性变差
3. **强制 OAuth 依赖**：语音功能完全绑定到 Anthropic OAuth，使用 API Key 的企业用户无法使用语音——如果 STT 端点独立于认证体系，这个限制可以解除
4. **`src/voice/` 目录只有一个文件**：整个 `src/voice/` 目录只包含 `voiceModeEnabled.ts`（54 行），这个目录的存在意义不大，可以合并到其他位置
5. **SoX 依赖**：在 Linux 上依赖 SoX 是一个老旧的选择，现代 Linux 桌面环境有 PipeWire/PulseAudio 的原生录音 API，SoX 在容器/WSL 环境中经常缺失
6. **终端键盘限制**：由于终端无法区分 Alt 和 Meta 键，语音快捷键的可用组合受到限制——这是终端应用的固有缺陷，但值得在文档中明确说明

### 技术定位

语音系统目前明显处于**早期实验阶段**——分散的代码组织、单文件目录、编译时特性门控都说明这个功能还在快速迭代中。语音输入在 AI 编程工具中已不算新鲜事物（GitHub Copilot Voice 和 Cursor 均有先例），但在 CLI 终端环境中实现完整的按住说话 + 流式 STT + 文本注入管线，Claude Code 是目前做得最完整的。当你在终端里双手被代码占据时，语音成为了一种自然的补充输入方式——这个方向整个行业都在探索。
