Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • Claude Code: Voice System & Buddy Companion System 深度分析

Claude Code: Voice System & Buddy Companion System 深度分析

源码快照路径: /home/sujie/dev/github/claude-code-source-snap/claude-code/src/


Part 1: Voice System(语音输入系统)

1.1 架构概览

Voice 系统采用 瘦网关 + 外部实现 的分层架构:

┌──────────────────────────────────────────────────────────────┐
│  UI Layer                                                    │
│  ┌─────────────────┐  ┌────────────────────────────────────┐ │
│  │ VoiceModeNotice  │  │ PromptInput (hold-to-talk key)    │ │
│  │ (启动提示,显示3次) │  │ interim transcript 实时预览        │ │
│  └─────────────────┘  └────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│  Hooks Layer                                                 │
│  ┌───────────────────┐  ┌──────────────────────────────────┐ │
│  │ useVoiceEnabled() │  │ useVoiceIntegration.tsx          │ │
│  │ (auth + GB 检查)   │  │ (键盘事件 → 录音 → transcript     │ │
│  └───────────────────┘  │  注入到 PromptInput cursor)       │ │
│                         └──────────────────────────────────┘ │
│  ┌───────────────────┐                                       │
│  │ useVoice.ts       │  ← 核心 hook: 录音状态机               │
│  │ (idle→recording→  │    + WebSocket 连接管理                │
│  │  processing)      │    + 音频 buffer/replay               │ │
│  └───────────────────┘                                       │
├──────────────────────────────────────────────────────────────┤
│  Services Layer                                               │
│  ┌─────────────────────┐  ┌────────────────────────────────┐ │
│  │ voice.ts            │  │ voiceStreamSTT.ts              │ │
│  │ (音频采集:           │  │ (WebSocket 客户端:              │ │
│  │  cpal native →      │  │  voice_stream endpoint,       │ │
│  │  arecord → SoX)     │  │  KeepAlive/CloseStream,       │ │
│  └─────────────────────┘  │  TranscriptText/Endpoint)     │ │
│  ┌─────────────────────┐  └────────────────────────────────┘ │
│  │ voiceKeyterms.ts    │                                      │
│  │ (STT 领域词汇增强)   │                                      │
│  └─────────────────────┘                                      │
├──────────────────────────────────────────────────────────────┤
│  Feature Gating                                               │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │ voice/voiceModeEnabled.ts                               │ │
│  │ (三层检查: compile-time feature + GrowthBook + OAuth)    │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

1.2 Feature Gating(三层网关)

src/voice/voiceModeEnabled.ts — 只有 54 行,是整个 voice 系统的"守门人":

// 第一层: 编译时特性开关 (bun:bundle)
feature('VOICE_MODE')

// 第二层: GrowthBook kill-switch — 紧急关闭用
// flag 名: tengu_amber_quartz_disabled
// 缺失/过期磁盘缓存默认为 false(未被 kill),新安装无需等待 GB 初始化
getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)

// 第三层: OAuth 认证 — voice_stream 是 claude.ai 私有 API
// 不支持 API key / Bedrock / Vertex / Foundry
getClaudeAIOAuthTokens()?.accessToken

组合检查函数:

  • isVoiceGrowthBookEnabled() — compile-time + GB kill-switch
  • hasVoiceAuth() — OAuth token 是否存在
  • isVoiceModeEnabled() — 两者合体,用于命令注册和 /voice 命令

React 渲染路径用 useVoiceEnabled()(src/hooks/useVoiceEnabled.ts),将 auth 检查结果 memoize 在 authVersion 上(只在 /login 时变化),避免每次渲染都触发 keychain 读取。

1.3 Voice Hooks 和集成点

useVoice.ts(核心 Hook,1144 行)

状态机: idle → recording → processing → idle

关键设计:

  1. Hold-to-talk 键盘检测:利用终端 auto-repeat(30-80ms 间隔),通过 200ms 间隔阈值判断按键释放。首次按键不立即设置释放定时器(等 auto-repeat 到来),避免 OS 初始延迟(~500ms)误触发释放。

  2. Focus mode:终端获得焦点时自动录音,失去焦点时停止。支持 "multi-clauding army" 工作流(多个终端窗口各跑一个 voice session)。5 秒无语音超时自动关闭。

  3. Session generation 计数:sessionGenRef 和 attemptGenRef 防止僵尸 WebSocket 回调污染新 session。

  4. Silent-drop replay:~1% 的 session-sticky CE pod bug — 服务端接受音频但返回零 transcript。检测到 no_data_timeout + hadAudioSignal + wsConnected 时,将缓冲的音频在新连接上重放一次。

  5. Early-error retry:WS 连接建立后、收到任何 transcript 前的错误,会等待 250ms 后重试一次(清除同-pod 碰撞)。

  6. 音频缓冲:录音在 WebSocket 连接建立之前就已开始,音频缓存在 audioBuffer 中,连接就绪后以 ~1s 切片批量发送。

  7. 语言支持:18 种语言的名称→BCP-47 代码映射,必须是服务端 speech_to_text_voice_stream_config GrowthBook allowlist 的子集,否则 WS 以 1008 "Unsupported language" 关闭。

useVoiceIntegration.tsx(677 行)

键盘事件 → 录音 → transcript 注入的完整管线:

  1. 按键激活:

    • Modifier 组合(meta+k, ctrl+x):首次按下即激活,无 hold 阈值
    • 裸字符(space, v):需要 5 次快速连续按键激活(防误触),前 2 次作为 warmup 透传
  2. Interim transcript 实时预览:通过 voicePrefixRef / voiceSuffixRef 保持光标位置,将转录文本插入到输入框的正确位置。

  3. CJK IME 兼容:全角空格 U+3000 被识别为空格等价物。

useVoiceKeybindingHandler(同文件)

桥接 useInput → onKeyDown 迁移。通过 useInput 订阅键盘事件,构造 KeyboardEvent 对象转发给 handleKeyDown,再在 stopImmediatePropagation 时同步拦截原始 InputEvent。

1.4 实际语音流实现

voiceStreamSTT.ts(544 行)— WebSocket 客户端

连接端点:/api/ws/speech_to_text/voice_stream

协议:

  • 客户端 → 服务端:二进制音频帧(16kHz, 16-bit, mono PCM)+ JSON 控制消息
    • {"type":"KeepAlive"} — 每 8 秒
    • {"type":"CloseStream"} — 结束音频流
  • 服务端 → 客户端:JSON 消息
    • TranscriptText — 转录文本(可能多次,非累积)
    • TranscriptEndpoint — utterance 结束标记
    • TranscriptError — 转录错误

连接细节:

  • 目标是 api.anthropic.com 而非 claude.ai(后者有 Cloudflare TLS 指纹识别,会挑战非浏览器客户端)
  • 使用 OAuth Bearer token 认证
  • 可通过 VOICE_STREAM_BASE_URL 环境变量覆盖
  • Nova 3 gate(tengu_cobalt_frost GB flag):启用后通过 conversation-engine + Deepgram Nova 3 路由

Finalize 机制(4 种触发源):

  1. post_closestream_endpoint — CloseStream 后收到 TranscriptEndpoint(最快,~300ms)
  2. no_data_timeout — 1.5s 内无数据(silent-drop 签名)
  3. ws_close — WebSocket 关闭事件(~3-5s)
  4. safety_timeout — 5s 兜底

voice.ts(525 行)— 音频采集

三层 fallback:

  1. cpal native(audio-capture-napi):macOS/Linux/Windows 进程内原生音频,通过 NAPI 绑定 CoreAudio/cpal
  2. arecord(ALSA utils):Linux fallback,需要通过 150ms probe 验证设备可用
  3. SoX rec:最后 fallback,支持静音检测自动停止

Native 模块懒加载策略:dlopen 是同步阻塞的(冷启动 ~8s),因此不在启动时加载,而是首次按住语音键时才触发。

voiceKeyterms.ts(106 行)— STT 词汇增强

向 Deepgram STT 引擎注入领域词汇(作为 "keywords" 参数),提高编码术语的识别准确率:

  • 全局词汇:MCP, symlink, grep, regex, localhost, TypeScript, JSON, OAuth, webhook, gRPC, dotfiles, subagent, worktree
  • 动态词汇:项目名、git 分支名(拆分 camelCase/kebab-case/snake_case)、最近文件名
  • 上限 50 个词

1.5 关键代码路径

路径文件行号说明
Feature gatesrc/voice/voiceModeEnabled.ts52-53isVoiceModeEnabled()
命令注册src/commands/voice/index.ts7-18/voice 命令定义
命令执行src/commands/voice/voice.ts16-150toggle + 预检
ConfigToolsrc/tools/ConfigTool/ConfigTool.ts116-124GB kill-switch 运行时检查
WebSocketsrc/services/voiceStreamSTT.ts111-543connectVoiceStream()
音频采集src/services/voice.ts335-396startRecording()
核心 Hooksrc/hooks/useVoice.ts199-1143useVoice()
集成层src/hooks/useVoiceIntegration.tsx118-347useVoiceIntegration()
键绑定src/hooks/useVoiceIntegration.tsx373-668useVoiceKeybindingHandler()
Voice Contextsrc/context/voice.tsx1-88VoiceProvider + store
启动提示src/components/LogoV2/VoiceModeNotice.tsx12-67显示3次后消失

Part 2: Buddy Companion System(伙伴宠物系统)

2.1 架构概览

┌──────────────────────────────────────────────────────────────┐
│  Feature Gate: feature('BUDDY') — compile-time switch        │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────────┐    ┌────────────────────────────────┐  │
│  │ companion.ts     │    │ CompanionSprite.tsx             │  │
│  │ (生成/回滚 bones) │───▶│ (ASCII 精灵 + SpeechBubble)     │  │
│  └──────────────────┘    │ - idle 序列动画 (500ms tick)    │  │
│                          │ - 宠物互动 (爱心飘浮)            │  │
│  ┌──────────────────┐    │ - 窄终端 fallback (单行脸)      │  │
│  │ sprites.ts       │    └────────────────────────────────┘  │
│  │ (18种物种精灵帧)  │                                         │
│  │ 每种3帧×5行×12宽 │    ┌────────────────────────────────┐  │
│  └──────────────────┘    │ useBuddyNotification.tsx        │  │
│                          │ - /buddy 彩虹 teaser             │  │
│  ┌──────────────────┐    │ - 4月1-7日窗口                   │  │
│  │ types.ts         │    │ - /buddy 文本高亮                │  │
│  │ (类型 + 常量)     │    └────────────────────────────────┘  │
│  └──────────────────┘                                         │
│  ┌──────────────────┐    ┌────────────────────────────────┐  │
│  │ prompt.ts        │    │ observer.ts (不在快照中)         │  │
│  │ (LLM context     │    │ fireCompanionObserver()         │  │
│  │  注入 companion  │    │ → companionReaction 状态        │  │
│  │  intro)          │    └────────────────────────────────┘  │
│  └──────────────────┘                                         │
├──────────────────────────────────────────────────────────────┤
│  Integration Points:                                          │
│  - REPL.tsx:2805 → fireCompanionObserver()                   │
│  - PromptInput.tsx:1986 → companionReaction 状态读取          │
│  - AppStateStore → companionReaction / companionPetAt         │
│  - config.ts → companion (StoredCompanion) / companionMuted  │
│  - attachments.ts → companion_intro attachment type           │
│  - messages.ts → companionIntroText → LLM system message     │
└──────────────────────────────────────────────────────────────┘

2.2 Companion 生成(确定性种子 PRNG)

核心在 src/buddy/companion.ts,整个生成系统基于 用户 ID 的确定性哈希:

const SALT = 'friend-2026-401'

// hashString: Bun.hash (如果在 Bun 中) 或 FNV-1a
// seed → Mulberry32 PRNG → 确定性结果
function roll(userId: string): Roll {
  const key = userId + SALT
  // 带缓存 — 同一 userId 多次调用返回缓存结果
  return rollFrom(mulberry32(hashString(key)))
}

关键设计决策:

  • companionUserId() 使用 oauthAccount?.accountUuid ?? config.userID ?? 'anon'
  • Bones(骨骼)从不持久化 — 每次读取都从 userId 重新生成。好处:改物种名不会破坏存档;用户无法通过编辑配置伪造稀有度
  • 只有 Soul(名字 + 性格)和 hatchedAt 时间戳存入 config(StoredCompanion 类型)
  • getCompanion() 从 config 读取 stored soul,再用 roll() 重新生成 bones,用 bones 覆盖 stored 中可能的旧字段

物种池(18 种)

所有物种名通过 String.fromCharCode() 构造(防 excluded-strings.txt canary 检测误报):

duck, goose, blob, cat, dragon, octopus, owl, penguin, turtle,
snail, ghost, axolotl, capybara, cactus, robot, rabbit, mushroom, chonk

稀有度系统

5 级稀有度,加权随机抽取:

RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 }
// 总权重 100 → legendary = 1% 概率

稀有度影响:

  • 属性下限:common=5, uncommon=15, rare=25, epic=35, legendary=50
  • 帽子:common 永远无帽子('none'),其他稀有度随机抽取
  • 颜色:common=inactive, uncommon=success, rare=permission, epic=autoAccept, legendary=warning
  • 星星显示:★ 到 ★★★★★

属性系统(Stats)

5 个属性:DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK

每只 companion 的属性生成策略:

  • 1 个 peak stat(峰值):floor + 50 + rng * 30,上限 100
  • 1 个 dump stat(洼地):max(1, floor - 10 + rng * 15)
  • 其余属性:floor + rng * 40

其他随机属性

  • 眼睛(6 种):·, ✦, ×, ◉, @, °
  • 帽子(8 种):none, crown, tophat, propeller, halo, wizard, beanie, tinyduck
  • 闪光(shiny):1% 概率

2.3 ASCII 精灵渲染和动画

src/buddy/sprites.ts — 514 行,18 种物种 × 3 帧的完整精灵表。

每个精灵:5 行高、12 字符宽({E} 占位符在渲染时替换为实际眼睛字符)。

// 鸭子示例
[duck]: [
  ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--´    '],  // 帧0: 静止
  ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--´~   '],  // 帧1: 尾巴摆动
  ['            ', '    __      ', '  <({E} )___  ', '   (  .__>  ', '    `--´    '],  // 帧2: 翅膀
]

帽子渲染在第 0 行(帽子槽),仅当第 0 行为空时才替换。

renderSprite() 处理流程:

  1. 取对应帧的 body 模板
  2. {E} → 实际眼睛字符
  3. 如果有帽子且第 0 行为空 → 插入帽子行
  4. 如果所有帧的第 0 行都为空 → 移除该行(节省空间)

renderFace() 生成单行迷你脸(用于窄终端模式)。

动画系统(CompanionSprite.tsx)

500ms tick 驱动:

Idle 序列(15 步循环):

const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
// 0 = 静止帧, 1/2 = 微动帧, -1 = 眨眼 (眼睛字符替换为 '-')

兴奋状态(有 reaction 或被 pet 时):循环所有帧,500ms 一帧。

Pet 动画:/buddy pet 触发 2.5 秒的心形飘浮效果:

const PET_HEARTS = [
  `   ♥    ♥   `,    // 帧0
  `  ♥  ♥   ♥  `,    // 帧1
  ` ♥   ♥  ♥   `,    // 帧2
  `♥  ♥      ♥ `,    // 帧3
  '·    ·   ·  ',    // 帧4 (消散)
]

窄终端模式

终端宽度 < 100 列时,精灵退化为单行迷你脸 + 名字(或当前 quip 截断到 24 字符)。

2.4 Speech Bubble 系统

const BUBBLE_SHOW = 20  // ticks → ~10秒(500ms/tick)
const FADE_WINDOW = 6   // 最后~3秒变暗(color → 'inactive')

Bubble 组件:

  • 使用 Ink 的 Box + Text 渲染(round border style)
  • 文本自动折行到 30 字符宽
  • 非全屏模式:bubble 内联在精灵右侧(挤压输入框宽度)
  • 全屏模式:CompanionFloatingBubble 渲染在 FullscreenLayout 的 bottomFloat 插槽中(不挤压输入框)

Tail 方向:

  • 非全屏:─ 连接线(right tail)
  • 全屏:╲ ╲ 对角线(down tail)

2.5 LLM Context 注入

src/buddy/prompt.ts — 通过 attachment 系统注入 conversation context:

export function companionIntroText(name: string, species: string): string {
  return `# Companion

A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.

When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
}

注入机制:

  1. getCompanionIntroAttachment() 检查 feature('BUDDY') + companion 存在 + 未静音
  2. 遍历已有 messages,如果已有同名 companion_intro attachment → 跳过(避免重复注入)
  3. 返回 [{ type: 'companion_intro', name, species }]
  4. messages.ts:4232 将 companion_intro attachment 转为 companionIntroText() 作为 system message

2.6 Observer 系统

src/buddy/observer.ts 在此快照中不存在(可能未发布),但其行为可从集成点推断:

  • REPL.tsx:2805 — 每次 query 结束后调用 fireCompanionObserver(messagesRef.current, callback)
  • callback 设置 companionReaction 状态 → 触发 SpeechBubble 显示
  • 推测 observer 是一个轻量级 LLM 调用,读取最近对话生成 companion 的 reaction(一句话评论)

2.7 启动集成和 Easter Egg

Teaser 窗口

src/buddy/useBuddyNotification.tsx:

// 2026年4月1-7日 teaser 窗口
export function isBuddyTeaserWindow(): boolean {
  if ("external" === 'ant') return true  // 内部构建永久显示
  const d = new Date()
  return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
}

// 永久上线时间: 2026年4月起
export function isBuddyLive(): boolean {
  if ("external" === 'ant') return true
  const d = new Date()
  return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
}

Teaser 效果:启动时如果用户未孵化 companion 且在 teaser 窗口内,显示彩虹色 /buddy 文字通知(15 秒超时,最高优先级)。

Anti-scraping 保护

src/buddy/types.ts 中所有物种名通过 String.fromCharCode() 拼接:

const c = String.fromCharCode
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'

注释明确说明:一个物种名与 excluded-strings.txt 中的 model codename canary 冲突。运行时拼接让字面量不出现在 bundle 中,同时保持 canary 检测对真实 codename 的有效性。

/buddy 命令

通过 feature('BUDDY') 条件加载(src/commands.ts:118-121),对应的 commands/buddy/index.js 在此快照中不存在,但从集成代码可推断支持子命令如 pet、mute 等。

findBuddyTriggerPositions() 在 useBuddyNotification.tsx:79-97 中检测用户输入中的 /buddy 关键字位置(可能用于高亮)。

2.8 关键代码路径

路径文件行号说明
生成src/buddy/companion.ts107-113roll() — 确定性生成
回滚src/buddy/companion.ts127-133getCompanion() — bones 不持久化
精灵表src/buddy/sprites.ts26-441BODIES — 18种×3帧
渲染src/buddy/sprites.ts454-469renderSprite()
帽子src/buddy/sprites.ts443-452HAT_LINES
UIsrc/buddy/CompanionSprite.tsx176-290CompanionSprite()
Bubblesrc/buddy/CompanionSprite.tsx43-151SpeechBubble()
浮动src/buddy/CompanionSprite.tsx296-358CompanionFloatingBubble()
LLM 注入src/buddy/prompt.ts7-13companionIntroText()
Attachmentsrc/buddy/prompt.ts15-36getCompanionIntroAttachment()
Teasersrc/buddy/useBuddyNotification.tsx12-21窗口判断
通知src/buddy/useBuddyNotification.tsx43-78useBuddyNotification()
类型src/buddy/types.ts1-148完整类型+常量
Observersrc/screens/REPL.tsx2804-2808fireCompanionObserver 调用
Configsrc/utils/config.ts269-271companion/companionMuted 字段

Part 3: 两个系统的 Feature Gating 模式对比

维度Voice SystemBuddy System
编译时开关feature('VOICE_MODE')feature('BUDDY')
GrowthBooktengu_amber_quartz_disabled(kill-switch)无
运行时检查OAuth token + GB无额外检查
DCE 模式require('./useVoice.js') 条件导入,替代品返回 no-oprequire('./commands/buddy/index.js') 条件导入
UI 隐藏/voice 命令 isHidden = !isVoiceModeEnabled()命令通过 buddy ? [buddy] : [] 数组
编译消除feature('VOICE_MODE') ? <VoiceModeNoticeInner /> : nullfeature('BUDDY') ? ... : null

两者的共同模式:

  1. feature() 是 bun:bundle 的编译时常量 — false 时整个分支被 dead-code-eliminate
  2. feature() ? require(...) : fallback — 避免无条件 import 引入依赖
  3. Runtime gate 在 UI 入口检查 — 即使编译时开关为 true,运行时 kill-switch 仍可关闭功能
  4. 组件/命令在 feature 关闭时返回 null — 无需条件渲染逻辑

Part 4: 集成矩阵

Voice System 集成点:
  PromptInput.tsx ← useVoiceIntegration ← useVoice ← voiceStreamSTT ← voice.ts
  REPL.tsx ← VoiceKeybindingHandler (useInput bridge)
  commands.ts ← commands/voice/index.ts (条件加载)
  ConfigTool ← voiceModeEnabled (运行时 GB 检查)

Buddy System 集成点:
  REPL.tsx:2805 ← fireCompanionObserver (每轮对话后)
  PromptInput.tsx:1986 ← companionReaction 状态读取
  AppStateStore ← companionReaction / companionPetAt
  messages.ts:4232 ← companion_intro attachment → LLM context
  config.ts ← companion (StoredCompanion) / companionMuted
  commands.ts ← commands/buddy/index.ts (条件加载)