语音系统
一键通、WebSocket STT、多后端音频捕获和 Claude Code 中的弹性模式。
1. 语音系统的作用
Claude Code 提供一键通语音输入管道,可将口语语音转换为提示文本。 按住一个键,说话,松开——文字记录准确地落在光标所在的提示输入中。 该系统受到两个独立守卫的控制:GrowthBook 功能标志(VOICE_MODE) 和 Anthropic OAuth 标记。 单独两者都不够。
┌──────────────┐ keypress ┌───────────────────┐ PCM chunks ┌──────────────────────┐
│ useVoiceInt- │ ──────────► │ useVoice.ts │ ────────────► │ voiceStreamSTT.ts │
│ egration.tsx │ │ (hold detection) │ │ (WebSocket STT) │
└──────────────┘ └────────┬──────────┘ └──────────┬───────────┘
│ startRecording() │ TranscriptText
┌────────▼──────────┐ ┌──────────▼───────────┐
│ voice.ts │ │ Anthropic voice_stream│
│ (audio backend) │ │ /api/ws/speech_to_ │
│ NAPI / arecord │ │ text/voice_stream │
│ / SoX │ └──────────────────────┘
└───────────────────┘
四个关键文件:
voice/voiceModeEnabled.ts— auth + 终止开关检查services/voice.ts— 音频录制后端(NAPI、arecord、SoX)services/voiceStreamSTT.ts— Anthropic 的 STT 端点的 WebSocket 客户端hooks/useVoice.ts— React hook 连接音频 → WS → 文字记录hooks/useVoiceIntegration.tsx— 提示输入集成、保持阈值、临时渲染
2. 功能门控和身份验证
三个功能构成了一个分层门:
isVoiceGrowthBookEnabled() — 终止开关
export function isVoiceGrowthBookEnabled(): boolean {
// feature('VOICE_MODE') is a compile-time constant (Bun bundler)
// Dead code is eliminated in non-ANT builds — keeps binary size down.
return feature('VOICE_MODE')
? !getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)
: false
}
成长书标志 tengu_amber_quartz_disabled 默认为 false (未禁用)。 丢失或陈旧的磁盘缓存读取为“未终止”——因此全新安装可以立即运行。 将旗帜翻转至 true 紧急情况 - 在 GrowthBook 的下一个缓存刷新周期内禁用所有用户的语音。
hasVoiceAuth() — OAuth 令牌检查
export function hasVoiceAuth(): boolean {
if (!isAnthropicAuthEnabled()) return false
const tokens = getClaudeAIOAuthTokens()
return Boolean(tokens?.accessToken)
}
getClaudeAIOAuthTokens() spawns security 在 macOS 上(大约 20-50 毫秒冷,之后缓存)。 记忆会在令牌刷新时清除(大约每小时一次),因此预计每个会话都会出现一次冷生成。 API 密钥、Bedrock、Vertex 和 Foundry 均在此处返回 false — voice_stream 仅可通过 Claude.ai OAuth 获取。
/voice 命令飞行前检查
启用语音在写入之前运行五次连续检查 voiceEnabled: true 到设置:
- isVoiceModeEnabled() — 授权 + GB 门。
- checkRecordingAvailability() - 拒绝远程环境(
CLAUDE_CODE_REMOTE、Homespace)立即; 然后探测音频后端链。 - isVoiceStreamAvailable() — 重新检查 OAuth 令牌的新鲜度。
- checkVoiceDependencies() — 验证至少一种录音工具可用。
- requestMicrophonePermission() — 现在触发 OS TCC 对话框 (macOS),而不是在第一次按键时触发。 提供针对特定平台的拒绝指南。
// Fires TCC dialog early — better than a surprise on first hold-to-talk
if (!(await requestMicrophonePermission())) {
const guidance = process.platform === 'darwin'
? 'System Settings → Privacy & Security → Microphone'
: 'your system\'s audio settings'
return { type: 'text', value: `Microphone access denied. Go to ${guidance}.` }
}
3. 录音后端
记录层呈现单一 startRecording(onData, onEnd, options?) 三个可能的后端的接口,按优先顺序尝试:
| Backend | Platforms | Notes | Status |
|---|---|---|---|
| audio-capture-napi | macOS、Linux(存在 ALSA 卡)、Windows | 通过 cpal + CoreAudio/AudioUnit 进行进程内。 dlopen 在 macOS 上阻止约 1 秒热,约 8 秒冷 — 仅在第一次语音按键时延迟加载。 |
Primary |
| arecord | 仅限 Linux | ALSA 用户空间实用程序。 通过 150 毫秒竞赛进行探测:如果仍然存在 = 设备打开正常。 通过 PulseAudio RDP 管道处理 WSL2+WSLg (Win11); 在 WSL1 / Win10-WSL2 上失败。 | 后备 1 |
| SoX(推荐) | macOS、Linux | 外部工艺管道原始 PCM。 需要 --buffer 1024 以防止内部缓冲延迟。 内置静音检测; arecord 没有。 |
后备2 |
| Windows(非母语) | 仅限 Windows | Windows 上没有子进程回退。 需要本机模块。 | 无后备方案 |
为什么 arecord 需要运行时探测,而不仅仅是 PATH 检查
在 WSL1 和无头 Linux 上, arecord 已安装但是 open() 由于没有 ALSA 卡和 PulseAudio 服务器,因此立即失败。 hasCommand('arecord') returns true 在所有这些情况下。 探头通过产卵来工作 arecord 使用与真实录制会话相同的参数并使用 150 毫秒的计时器:
const timer = setTimeout((child, resolve) => {
child.kill('SIGTERM')
resolve({ ok: true, stderr: '' }) // still alive = opened OK
}, 150, child, resolve)
child.once('close', code => {
clearTimeout(timer)
resolve({ ok: code === 0, stderr: stderr.trim() }) // exited early = failed
})
结果被记忆 - 音频设备可用性不会在会话中改变,并且这在每次语音按键时运行 checkRecordingAvailability().
SoX 争论细节和沉默检测
const args = [
'-q', // quiet: no progress output
'--buffer', '1024', // flush in small chunks; without this SoX buffers seconds
'-t', 'raw', // raw PCM, no WAV header
'-r', '16000', // 16kHz sample rate (matches STT endpoint requirement)
'-e', 'signed', // signed PCM
'-b', '16', // 16-bit depth
'-c', '1', // mono
'-', // write to stdout
]
// Silence detection (only when NOT in push-to-talk mode)
if (useSilenceDetection) {
args.push('silence', '1', '0.1', '3%', '1', '2.0', '3%')
// ↑ stop after 2 seconds of audio below 3% threshold
}
一键通通行证 { silenceDetection: false } — 用户控制启动和停止。 本机 NAPI 模块也会忽略其内置的 onEnd 在一键通模式下。
4.WebSocket STT协议
STT 客户端连接到 wss://api.anthropic.com/api/ws/speech_to_text/voice_stream。 它使用与 Claude Code 的其余部分相同的 OAuth 不记名令牌。 URL 包含配置 STT 会话的查询参数:
const params = new URLSearchParams({
encoding: 'linear16', // 16-bit signed PCM
sample_rate: '16000',
channels: '1', // mono
endpointing_ms: '300', // endpoint detection window
utterance_end_ms:'1000',
language: options?.language ?? 'en',
})
有线协议消息
Buffer.from() 以防止 NAPI 共享 ArrayBuffer 竞争。setTimeout(0) 首先刷新任何排队的 NAPI 回调。isFinal=false.TranscriptText to isFinal=true。 后 CloseStream, 解决 finalize() 快(~300 毫秒)。onError.为什么选择 api.anthropic.com 而不是 claude.ai
claude.ai Cloudflare 区域使用 TLS 指纹并阻止非浏览器客户端(JA3 指纹不匹配)。 这 api.anthropic.com 侦听器公开具有相同 OAuth 身份验证的相同 private-api pod,但位于不强制执行浏览器级 TLS 指纹识别的 CF 区域。 桌面听写仍然使用 claude.ai 因为 Swift 的 URLSession 具有浏览器级 JA3 指纹并通过了挑战。
// Override via env var for testing/staging
const wsBaseUrl =
process.env.VOICE_STREAM_BASE_URL ||
getOauthConfig().BASE_API_URL
.replace('https://', 'wss://')
.replace('http://', 'ws://')
Finalize() — 四个分辨率触发器
finalize() 返回一个 Promise<FinalizeSource> 通过四个路径中最先触发的一个来解决:
| Source | Condition | 典型延迟 |
|---|---|---|
post_closestream_endpoint | CloseStream 发送后 TranscriptEndpoint 到达 | 约 300 毫秒 |
no_data_timeout | CloseStream(1.5 秒)后没有到达 TranscriptText | 1.5秒 |
ws_close | WebSocket 关闭事件触发 | 3-5秒 |
safety_timeout | 最后手段上限 | 5秒 |
The no_data_timeout 路径是 无声签名 — 如果它触发 hadAudioSignal=true,会话遇到了一个已知的服务器错误(粘性 CE pod 返回零转录本,约 1% 的会话)。
5. 保持通话机制和保持阈值
终端按键事件以流的形式到达:首次按下时发生一个事件,然后在按住时每 30-80 毫秒自动重复一次事件。 终端中没有“keyup”事件。 系统通过事件之间的时间间隔重建保持。
// In useVoiceIntegration.tsx
const RAPID_KEY_GAP_MS = 120 // auto-repeat fires every 30-80ms; 120ms covers jitter
const HOLD_THRESHOLD = 5 // 5 rapid presses required before activating voice
const WARMUP_THRESHOLD = 2 // show "keep holding…" feedback at press #2
修饰符组合绑定(例如 Ctrl+Space)在第一次按下时激活 - 无需预热,因为修饰符组合显然是有意为之的。
stripTrailing() — 清理泄漏的空间
当用户按住 Space 进行热身时,一些空格字符会泄漏到之前的文本输入中 stopImmediatePropagation() 生效(不保证监听器注册顺序)。 stripTrailing() 精确删除泄漏计数,而不触及边界处预先存在的空间:
// Strip exactly `maxStrip` trailing `char` chars, leaving `floor` behind
const stripTrailing = (maxStrip, { char = ' ', anchor = false, floor = 0 } = {}) => {
// Also counts full-width spaces (U+3000) for CJK IME compatibility
const scan = char === ' '
? normalizeFullWidthSpace(beforeCursor)
: beforeCursor
// ...
if (anchor) {
voicePrefixRef.current = stripped // save text before cursor
voiceSuffixRef.current = afterCursor // save text after cursor
}
}
When anchor=true,该调用还捕获临时转录本注入的光标位置。 在前缀和后缀之间插入的间隙确保波形光标位于间隙上而不是第一个后缀字母上。
释放检测:RELEASE_TIMEOUT_MS 和 REPEAT_FALLBACK_MS
// In useVoice.ts
const RELEASE_TIMEOUT_MS = 200 // gap that signals key release (auto-repeat is 30-80ms)
const REPEAT_FALLBACK_MS = 600 // arm release timer if no auto-repeat seen yet
const FIRST_PRESS_FALLBACK_MS = 2000 // modifier combos: OS initial repeat delay up to ~2s
当 600 毫秒内没有第二次按键到达时,回退计时器将启动释放检测。 对于修饰符组合,调用者通过 2000 毫秒来覆盖较长的操作系统初始重复延迟(macOS 滑块位于“长”= 自动重复开始前约 2 秒)。
6. 会话状态机
每个记录会话都会经历由以下管理的三个状态 useVoice:
updateState('recording') 被称为 在任何等待之前同步 in startRecordingSession(). useVoiceIntegration reads voiceState 立即从商店购买 void startRecordingSession() 门泄漏的空间按键是否应该被吞掉。 如果首先运行等待,守卫会看到陈旧的 'idle' 并让空间泄漏。
完整的会话生命周期时间表
7.静默回放
大约 1% 的会话遇到了服务器端错误(会话粘性 CE pod 接受音频但返回零记录)。 症状: finalize() 通过解决 no_data_timeout 尽管是真实的言语。 客户端检测到此模式并在新的 WebSocket 上重播完整的音频缓冲区一次。
Silent-drop检测条件和重放代码
所有六个条件必须满足才能触发重播:
finalizeSource === 'no_data_timeout'hadAudioSignal === true(检测到重要的麦克风信号)wsConnected === true(WS确实打开——后端收到音频)!focusTriggered(不是焦点模式会话)accumulatedRef.current.trim() === ''(没有积累部分成绩单)!silentDropRetriedRef.current(每个会话仅重播一次)
if (finalizeSource === 'no_data_timeout' && hadAudioSignal && wsConnected
&& !focusTriggered && focusFlushedChars === 0
&& accumulatedRef.current.trim() === ''
&& !silentDropRetriedRef.current
&& fullAudioRef.current.length > 0) {
silentDropRetriedRef.current = true
await sleep(250) // backoff to clear rapid-reconnect same-pod race
if (isStale()) return
// Replay full buffer in 32 KB slices on a fresh connection
const SLICE = 32_000
for (const chunk of replayBuffer) {
// ... batch into SLICE-sized sends ...
conn.send(Buffer.concat(slice))
}
await conn.finalize()
}
音频缓冲区是有界的: fullAudioRef.current 在焦点模式下跳过缓冲(会话可能持续几分钟,缓冲区可能达到约 20 MB)。 32 KB 切片大小将小 NAPI 块批处理为合理的 WS 帧大小,而不会超出 WS 消息限制。
8. 对焦模式
焦点模式是一种“多方军队”工作流程:当终端窗口获得焦点时开始录制,当失去焦点时停止录制。 转录块会立即刷新(而不是累积),因此长时间会话中的连续听写可以保持响应。
对焦模式与保持通话的区别
| Behavior | Hold-to-talk | 对焦模式 |
|---|---|---|
| Trigger | 按键保持 | 终端焦点增益 |
| 停止触发 | 按键释放(间隙 > 200 毫秒) | 终端焦点丢失 |
| 成绩单交付 | 累积,停止注入 | 每一次决赛都会立即泛红; 锚先进 |
| 静音超时 | None | 5 秒(FOCUS_SILENCE_TIMEOUT_MS) — 拆除会话以释放 WS |
| 无声重放 | Yes | 否(选通 !focusTriggered) |
| 音频缓冲区 | 保留完整缓冲区以供重播 | 跳过(长时间训练中的自重) |
// Arms / resets the silence timer after each flushed transcript
function armFocusSilenceTimer(): void {
if (focusSilenceTimerRef.current) clearTimeout(focusSilenceTimerRef.current)
focusSilenceTimerRef.current = setTimeout(() => {
if (stateRef.current === 'recording' && focusTriggeredRef.current) {
silenceTimedOutRef.current = true
finishRecording() // tears down WS gracefully
}
}, FOCUS_SILENCE_TIMEOUT_MS) // 5000 ms
}
9. 语言规范化和关键术语
语言规范化
normalizeLanguageForSTT() 映射用户的 settings.language 字符串(可能是“日语”、“日本语”、“ja-JP”等)到硬编码白名单中的 BCP-47 代码,该白名单是服务器的子集 speech_to_text_voice_stream_config GrowthBook 许可名单。 发送不受支持的代码会关闭 WebSocket,并显示代码 1008“不支持的语言”。
// Falls back to 'en' with a fellBackFrom warning if language is unsupported
export function normalizeLanguageForSTT(language): { code: string, fellBackFrom?: string } {
if (!language) return { code: 'en' }
const lower = language.toLowerCase().trim()
if (SUPPORTED_LANGUAGE_CODES.has(lower)) return { code: lower }
const fromName = LANGUAGE_NAME_TO_CODE[lower] // e.g. "japanese" → "ja"
if (fromName) return { code: fromName }
const base = lower.split('-')[0] // "ja-JP" → "ja"
if (SUPPORTED_LANGUAGE_CODES.has(base)) return { code: base }
return { code: 'en', fellBackFrom: language }
}
关键术语(STT 提升)
The getVoiceKeyterms() 函数构建最多 50 个特定于域的术语的列表,发送为 keyterms 查询参数。 STT 后端应用增强功能,以便正确识别“MCP”、“OAuth”、“TypeScript”和特定于项目的词汇。
关键术语来源和标识符分割
关键词来自三个来源,并合并到一个去重复的集中:
- 全局硬编码术语:MCP、符号链接、grep、正则表达式、localhost、TypeScript、OAuth、webhook、gRPC、dotfiles、子代理、工作树。 (Claude 和 Anthropic 已经是服务器上的基本关键字。)
- 项目根基名: 例如,
claude-code作为一个整体添加。 - Git 分支词:
feat/voice-keyterms→ [“壮举”、“声音”、“关键术语”]。
export function splitIdentifier(name: string): string[] {
return name
.replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase → camel Case
.split(/[-_./\s]+/) // split on separators
.filter(w => w.length > 2 && w.length <= 20) // discard noise
}
10. 音频电平可视化和RMS计算
录音时,提示输入显示 16 条波形。 每个新的 PCM 块通过计算原始 16 位签名 PCM 缓冲区的 RMS 幅度来更新最右边的条。
const AUDIO_LEVEL_BARS = 16
export function computeLevel(chunk: Buffer): number {
const samples = chunk.length >> 1 // 16-bit = 2 bytes per sample
if (samples === 0) return 0
let sumSq = 0
for (let i = 0; i < chunk.length - 1; i += 2) {
// Read 16-bit signed little-endian sample
const sample = ((chunk[i]! | (chunk[i+1]! << 8)) << 16) >> 16
sumSq += sample * sample
}
const rms = Math.sqrt(sumSq / samples)
const normalized = Math.min(rms / 2000, 1)
return Math.sqrt(normalized) // sqrt curve spreads quieter levels visually
}
sqrt(normalized) 将更安静的级别 (0.0–0.5) 分布在更多条上,使可视化响应正常的对话语音。
11.React上下文层
语音状态存储在自定义的 Store<VoiceState> (不是 React 状态)保存在上下文中。 这使得 useSyncExternalStore基于 - 的订阅,仅在选定切片更改时重新渲染。
export type VoiceState = {
voiceState: 'idle' | 'recording' | 'processing'
voiceError: string | null
voiceInterimTranscript: string // live preview text shown in prompt
voiceAudioLevels: number[] // 16 bars, 0–1 normalized
voiceWarmingUp: boolean // show "keep holding…" hint
}
useVoiceEnabled — 为什么 auth 在 authVersion 上被记忆
export function useVoiceEnabled(): boolean {
const userIntent = useAppState(s => s.settings.voiceEnabled === true)
const authVersion = useAppState(s => s.authVersion)
// authVersion bumps on /login only.
// getClaudeAIOAuthTokens() spawns `security` (~60ms cold) — can't call on every render.
const authed = useMemo(hasVoiceAuth, [authVersion])
return userIntent && authed && isVoiceGrowthBookEnabled()
}
The isVoiceGrowthBookEnabled() 调用保留在备忘录之外,因此会话中的终止开关翻转会在下一次渲染时生效,而无需等待登录事件。
12. 早期错误重试
CE 代理可以拒绝快速重新连接(~1/N_pods 相同 Pod 冲突),并且 Deepgram 的上游可能在其自己的拆卸窗口期间失败。 这些在任何成绩单到达之前都会表现为错误。 系统会以 250 毫秒的退避时间重试一次。
// Only retry if: not fatal (4xx), no transcript seen yet, still recording
if (!opts?.fatal && !sawTranscript && stateRef.current === 'recording') {
if (!retryUsedRef.current) {
retryUsedRef.current = true
connectionRef.current = null // null → audio re-buffers until new onReady
attemptGenRef.current++ // stale conn's trailing close is ignored
setTimeout(() => {
if (stateRef.current === 'recording') attemptConnect(keyterms)
}, 250)
return
}
}
// Fatal errors (4xx) surface the message to the user
fatal: true 由 unexpected-response 处理程序。 致命错误永远不会重试——相同的请求将得到相同的拒绝。
要点
- 语音是双门控的。 成长手册
VOICE_MODE需要功能标志(编译时死代码消除)和 Anthropic OAuth 标记。 终止开关默认为“未终止”,因此全新安装会立即生效。 API 密钥、Bedrock、Vertex 和 Foundry 被设计排除在外。 - 后端后备链与可用性检查链相匹配。
startRecording()andcheckRecordingAvailability()遵循相同的 NAPI → arecord → SoX 优先顺序。 已记下的probeArecord()结果确保,如果可用性检查失败到 SoX(损坏的记录),录音调用也会失败。 - 音频在 WebSocket 打开之前开始。 PCM 块缓冲在
audioBuffer[]untilonReady火灾。 这消除了从用户感知到的录制开始起的 1–2 秒 OAuth+WS 连接延迟。 - 在任何等待之前,状态同步转换为“记录”。 此转换之前的任何异步工作都会让保持检测代码看到陈旧的“空闲”,并允许自动重复关键字符泄漏到文本输入中。
- 保持阈值(快速按下 5 次)可防止意外激活裸字符绑定。 修饰符组合完全绕过了门槛。
stripTrailing()清理泄漏的字符而不干扰光标边界处预先存在的内容。 - 无提示重放是针对服务器端错误的客户端解决方法。 完整的音频缓冲区是专门为这一一次性重放而保留的。 焦点模式会跳过缓冲,以避免长时间会话中的多 MB 累积。
finalize()有四个具有不同延迟的分辨率触发器。 快速路径(post_closestream_endpoint,~300 ms)是正常情况。no_data_timeout(1.5 s)是无声跌落检测器。 始终捕捉recordingDurationMs之前finalize()wait 可防止 WebSocket 拆卸时间夸大指标。- 焦点模式在结构上与保持通话不同。 它禁用音频缓冲,用立即刷新代替累积,并使用 5 秒静音计时器而不是按键释放作为停止触发器。
测验
updateState('recording') 在任何之前同步运行 await in startRecordingSession()?useVoiceIntegration的保持检测代码将变得陈旧 'idle' 并且无法吞掉自动重复空格,导致空格泄漏到提示输入中。arecord and sox 安装后,什么决定实际使用哪个后端?hasCommand('arecord') 只检查路径。 在无头 Linux 上, arecord 存在但是 open() 如果没有 ALSA 卡,就会立即失败。 150 ms 竞赛检测到这一点:如果 arecord 在计时器触发之前退出, probe.ok = false 并用SoX代替。 该决定会在会议中被记住。no_data_timeout 分辨率来源 finalize() 旨在检测?no_data_timeout 火灾与 hadAudioSignal=true and wsConnected=true,这意味着音频到达后端,但没有返回任何文字记录——这是 ~1% 静默丢失错误的签名。 然后,客户端在新的 WebSocket 连接上重播完整的音频缓冲区。connectVoiceStream() target api.anthropic.com 而不是 claude.ai?fullAudioRef)在焦点模式下跳过?!focusTriggered,缓冲区在焦点模式下没有任何作用,并且浪费内存。normalizeLanguageForSTT() 收到“斯瓦希里语”等不受支持的语言?{ code: 'en', fellBackFrom: 'Swahili' }。 这 /voice 命令处理程序检查 stt.fellBackFrom 并在启用确认消息中附加一条注释,例如“斯瓦希里语不是受支持的听写语言;使用英语”。