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-switchhasVoiceAuth()— 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
关键设计:
Hold-to-talk 键盘检测:利用终端 auto-repeat(30-80ms 间隔),通过 200ms 间隔阈值判断按键释放。首次按键不立即设置释放定时器(等 auto-repeat 到来),避免 OS 初始延迟(~500ms)误触发释放。
Focus mode:终端获得焦点时自动录音,失去焦点时停止。支持 "multi-clauding army" 工作流(多个终端窗口各跑一个 voice session)。5 秒无语音超时自动关闭。
Session generation 计数:
sessionGenRef和attemptGenRef防止僵尸 WebSocket 回调污染新 session。Silent-drop replay:~1% 的 session-sticky CE pod bug — 服务端接受音频但返回零 transcript。检测到
no_data_timeout+hadAudioSignal+wsConnected时,将缓冲的音频在新连接上重放一次。Early-error retry:WS 连接建立后、收到任何 transcript 前的错误,会等待 250ms 后重试一次(清除同-pod 碰撞)。
音频缓冲:录音在 WebSocket 连接建立之前就已开始,音频缓存在
audioBuffer中,连接就绪后以 ~1s 切片批量发送。语言支持:18 种语言的名称→BCP-47 代码映射,必须是服务端
speech_to_text_voice_stream_configGrowthBook allowlist 的子集,否则 WS 以 1008 "Unsupported language" 关闭。
useVoiceIntegration.tsx(677 行)
键盘事件 → 录音 → transcript 注入的完整管线:
按键激活:
- Modifier 组合(meta+k, ctrl+x):首次按下即激活,无 hold 阈值
- 裸字符(space, v):需要 5 次快速连续按键激活(防误触),前 2 次作为 warmup 透传
Interim transcript 实时预览:通过
voicePrefixRef/voiceSuffixRef保持光标位置,将转录文本插入到输入框的正确位置。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_frostGB flag):启用后通过 conversation-engine + Deepgram Nova 3 路由
Finalize 机制(4 种触发源):
post_closestream_endpoint— CloseStream 后收到 TranscriptEndpoint(最快,~300ms)no_data_timeout— 1.5s 内无数据(silent-drop 签名)ws_close— WebSocket 关闭事件(~3-5s)safety_timeout— 5s 兜底
voice.ts(525 行)— 音频采集
三层 fallback:
- cpal native(
audio-capture-napi):macOS/Linux/Windows 进程内原生音频,通过 NAPI 绑定 CoreAudio/cpal - arecord(ALSA utils):Linux fallback,需要通过 150ms probe 验证设备可用
- 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 gate | src/voice/voiceModeEnabled.ts | 52-53 | isVoiceModeEnabled() |
| 命令注册 | src/commands/voice/index.ts | 7-18 | /voice 命令定义 |
| 命令执行 | src/commands/voice/voice.ts | 16-150 | toggle + 预检 |
| ConfigTool | src/tools/ConfigTool/ConfigTool.ts | 116-124 | GB kill-switch 运行时检查 |
| WebSocket | src/services/voiceStreamSTT.ts | 111-543 | connectVoiceStream() |
| 音频采集 | src/services/voice.ts | 335-396 | startRecording() |
| 核心 Hook | src/hooks/useVoice.ts | 199-1143 | useVoice() |
| 集成层 | src/hooks/useVoiceIntegration.tsx | 118-347 | useVoiceIntegration() |
| 键绑定 | src/hooks/useVoiceIntegration.tsx | 373-668 | useVoiceKeybindingHandler() |
| Voice Context | src/context/voice.tsx | 1-88 | VoiceProvider + store |
| 启动提示 | src/components/LogoV2/VoiceModeNotice.tsx | 12-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() 处理流程:
- 取对应帧的 body 模板
{E}→ 实际眼睛字符- 如果有帽子且第 0 行为空 → 插入帽子行
- 如果所有帧的第 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.`
}
注入机制:
getCompanionIntroAttachment()检查feature('BUDDY')+ companion 存在 + 未静音- 遍历已有 messages,如果已有同名 companion_intro attachment → 跳过(避免重复注入)
- 返回
[{ type: 'companion_intro', name, species }] messages.ts:4232将companion_introattachment 转为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.ts | 107-113 | roll() — 确定性生成 |
| 回滚 | src/buddy/companion.ts | 127-133 | getCompanion() — bones 不持久化 |
| 精灵表 | src/buddy/sprites.ts | 26-441 | BODIES — 18种×3帧 |
| 渲染 | src/buddy/sprites.ts | 454-469 | renderSprite() |
| 帽子 | src/buddy/sprites.ts | 443-452 | HAT_LINES |
| UI | src/buddy/CompanionSprite.tsx | 176-290 | CompanionSprite() |
| Bubble | src/buddy/CompanionSprite.tsx | 43-151 | SpeechBubble() |
| 浮动 | src/buddy/CompanionSprite.tsx | 296-358 | CompanionFloatingBubble() |
| LLM 注入 | src/buddy/prompt.ts | 7-13 | companionIntroText() |
| Attachment | src/buddy/prompt.ts | 15-36 | getCompanionIntroAttachment() |
| Teaser | src/buddy/useBuddyNotification.tsx | 12-21 | 窗口判断 |
| 通知 | src/buddy/useBuddyNotification.tsx | 43-78 | useBuddyNotification() |
| 类型 | src/buddy/types.ts | 1-148 | 完整类型+常量 |
| Observer | src/screens/REPL.tsx | 2804-2808 | fireCompanionObserver 调用 |
| Config | src/utils/config.ts | 269-271 | companion/companionMuted 字段 |
Part 3: 两个系统的 Feature Gating 模式对比
| 维度 | Voice System | Buddy System |
|---|---|---|
| 编译时开关 | feature('VOICE_MODE') | feature('BUDDY') |
| GrowthBook | tengu_amber_quartz_disabled(kill-switch) | 无 |
| 运行时检查 | OAuth token + GB | 无额外检查 |
| DCE 模式 | require('./useVoice.js') 条件导入,替代品返回 no-op | require('./commands/buddy/index.js') 条件导入 |
| UI 隐藏 | /voice 命令 isHidden = !isVoiceModeEnabled() | 命令通过 buddy ? [buddy] : [] 数组 |
| 编译消除 | feature('VOICE_MODE') ? <VoiceModeNoticeInner /> : null | feature('BUDDY') ? ... : null |
两者的共同模式:
feature()是 bun:bundle 的编译时常量 — false 时整个分支被 dead-code-eliminatefeature() ? require(...) : fallback— 避免无条件 import 引入依赖- Runtime gate 在 UI 入口检查 — 即使编译时开关为 true,运行时 kill-switch 仍可关闭功能
- 组件/命令在 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 (条件加载)