Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
17 kB · 525 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526// Voice service: audio recording for push-to-talk voice input.//// Recording uses native audio capture (cpal) on macOS, Linux, and Windows// for in-process mic access. Falls back to SoX `rec` or arecord (ALSA)// on Linux if the native module is unavailable.
import { type ChildProcess, spawn, spawnSync } from 'child_process'import { readFile } from 'fs/promises'import { logForDebugging } from '../utils/debug.js'import { isEnvTruthy, isRunningOnHomespace } from '../utils/envUtils.js'import { logError } from '../utils/log.js'import { getPlatform } from '../utils/platform.js'
// Lazy-loaded native audio module. audio-capture.node links against// CoreAudio.framework + AudioUnit.framework; dlopen is synchronous and// blocks the event loop for ~1s warm, up to ~8s on cold coreaudiod// (post-wake, post-boot). Load happens on first voice keypress — no// preload, because there's no way to make dlopen non-blocking and a// startup freeze is worse than a first-press delay.type AudioNapi = typeof import('audio-capture-napi')let audioNapi: AudioNapi | null = nulllet audioNapiPromise: Promise<AudioNapi> | null = null
function loadAudioNapi(): Promise<AudioNapi> { audioNapiPromise ??= (async () => { const t0 = Date.now() const mod = await import('audio-capture-napi') // vendor/audio-capture-src/index.ts defers require(...node) until the // first function call — trigger it here so timing reflects real cost. mod.isNativeAudioAvailable() audioNapi = mod logForDebugging(`[voice] audio-capture-napi loaded in ${Date.now() - t0}ms`) return mod })() return audioNapiPromise}
// ─── Constants ───────────────────────────────────────────────────────
const RECORDING_SAMPLE_RATE = 16000const RECORDING_CHANNELS = 1
// SoX silence detection: stop after this duration of silenceconst SILENCE_DURATION_SECS = '2.0'const SILENCE_THRESHOLD = '3%'
// ─── Dependency check ────────────────────────────────────────────────
function hasCommand(cmd: string): boolean { // Spawn the target directly instead of `which cmd`. On Termux/Android // `which` is a shell builtin — the external binary is absent or // kernel-blocked (EPERM) when spawned from Node. Only reached on // non-Windows (win32 returns early from all callers), no PATHEXT issue. // result.error is set iff the spawn itself fails (ENOENT/EACCES); exit // code is irrelevant — an unrecognized --version still means cmd exists. const result = spawnSync(cmd, ['--version'], { stdio: 'ignore', timeout: 3000, }) return result.error === undefined}
// Probe whether arecord can actually open a capture device. hasCommand()// only checks PATH; on WSL1/Win10-WSL2/headless Linux the binary exists// but fails at open() because there is no ALSA card and no PulseAudio// server. On WSL2+WSLg (Win11), PulseAudio works via RDP pipes and arecord// succeeds. We spawn with the same args as startArecordRecording() and race// a short timer: if the process is still alive after 150ms it opened the// device; if it exits early the stderr tells us why. Memoized — audio// device availability does not change mid-session, and this is called on// every voice keypress via checkRecordingAvailability().type ArecordProbeResult = { ok: boolean; stderr: string }let arecordProbe: Promise<ArecordProbeResult> | null = null
function probeArecord(): Promise<ArecordProbeResult> { arecordProbe ??= new Promise(resolve => { const child = spawn( 'arecord', [ '-f', 'S16_LE', '-r', String(RECORDING_SAMPLE_RATE), '-c', String(RECORDING_CHANNELS), '-t', 'raw', '/dev/null', ], { stdio: ['ignore', 'ignore', 'pipe'] }, ) let stderr = '' child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) const timer = setTimeout( (c: ChildProcess, r: (v: ArecordProbeResult) => void) => { c.kill('SIGTERM') r({ ok: true, stderr: '' }) }, 150, child, resolve, ) child.once('close', code => { clearTimeout(timer) // SIGTERM close (code=null) after timer fired is already resolved. // Early close with code=0 is unusual (arecord shouldn't exit on its // own) but treat as ok. void resolve({ ok: code === 0, stderr: stderr.trim() }) }) child.once('error', () => { clearTimeout(timer) void resolve({ ok: false, stderr: 'arecord: command not found' }) }) }) return arecordProbe}
export function _resetArecordProbeForTesting(): void { arecordProbe = null}
// cpal's ALSA backend writes to our process stderr when it can't find any// sound cards (it runs in-process — no subprocess pipe to capture it). The// spawn fallbacks below pipe stderr correctly, so skip native when ALSA has// nothing to open. Memoized: card presence doesn't change mid-session.let linuxAlsaCardsMemo: Promise<boolean> | null = null
function linuxHasAlsaCards(): Promise<boolean> { linuxAlsaCardsMemo ??= readFile('/proc/asound/cards', 'utf8').then( cards => { const c = cards.trim() return c !== '' && !c.includes('no soundcards') }, () => false, ) return linuxAlsaCardsMemo}
export function _resetAlsaCardsForTesting(): void { linuxAlsaCardsMemo = null}
type PackageManagerInfo = { cmd: string args: string[] displayCommand: string}
function detectPackageManager(): PackageManagerInfo | null { if (process.platform === 'darwin') { if (hasCommand('brew')) { return { cmd: 'brew', args: ['install', 'sox'], displayCommand: 'brew install sox', } } return null }
if (process.platform === 'linux') { if (hasCommand('apt-get')) { return { cmd: 'sudo', args: ['apt-get', 'install', '-y', 'sox'], displayCommand: 'sudo apt-get install sox', } } if (hasCommand('dnf')) { return { cmd: 'sudo', args: ['dnf', 'install', '-y', 'sox'], displayCommand: 'sudo dnf install sox', } } if (hasCommand('pacman')) { return { cmd: 'sudo', args: ['pacman', '-S', '--noconfirm', 'sox'], displayCommand: 'sudo pacman -S sox', } } }
return null}
export async function checkVoiceDependencies(): Promise<{ available: boolean missing: string[] installCommand: string | null}> { // Native audio module (cpal) handles everything on macOS, Linux, and Windows const napi = await loadAudioNapi() if (napi.isNativeAudioAvailable()) { return { available: true, missing: [], installCommand: null } }
// Windows has no supported fallback — native module is required if (process.platform === 'win32') { return { available: false, missing: ['Voice mode requires the native audio module (not loaded)'], installCommand: null, } }
// On Linux, arecord (ALSA utils) is a valid fallback recording backend if (process.platform === 'linux' && hasCommand('arecord')) { return { available: true, missing: [], installCommand: null } }
const missing: string[] = []
if (!hasCommand('rec')) { missing.push('sox (rec command)') }
const pm = missing.length > 0 ? detectPackageManager() : null return { available: missing.length === 0, missing, installCommand: pm?.displayCommand ?? null, }}
// ─── Recording availability ──────────────────────────────────────────
export type RecordingAvailability = { available: boolean reason: string | null}
// Probe-record through the full fallback chain (native → arecord → SoX)// to verify that at least one backend can record. On macOS this also// triggers the TCC permission dialog on first use. We trust the probe// result over the TCC status API, which can be unreliable for ad-hoc// signed or cross-architecture binaries (e.g., x64-on-arm64).export async function requestMicrophonePermission(): Promise<boolean> { const napi = await loadAudioNapi() if (!napi.isNativeAudioAvailable()) { return true // non-native platforms skip this check }
const started = await startRecording( _chunk => {}, // discard audio data — this is a permission probe only () => {}, // ignore silence-detection end signal { silenceDetection: false }, ) if (started) { stopRecording() return true } return false}
export async function checkRecordingAvailability(): Promise<RecordingAvailability> { // Remote environments have no local microphone if (isRunningOnHomespace() || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { return { available: false, reason: 'Voice mode requires microphone access, but no audio device is available in this environment.\n\nTo use voice mode, run Claude Code locally instead.', } }
// Native audio module (cpal) handles everything on macOS, Linux, and Windows const napi = await loadAudioNapi() if (napi.isNativeAudioAvailable()) { return { available: true, reason: null } }
// Windows has no supported fallback if (process.platform === 'win32') { return { available: false, reason: 'Voice recording requires the native audio module, which could not be loaded.', } }
const wslNoAudioReason = 'Voice mode could not access an audio device in WSL.\n\nWSL2 with WSLg (Windows 11) provides audio via PulseAudio — if you are on Windows 10 or WSL1, run Claude Code in native Windows instead.'
// On Linux (including WSL), probe arecord. hasCommand() is insufficient: // the binary can exist while the device open() fails (WSL1, Win10-WSL2, // headless Linux). WSL2+WSLg (Win11 default) works via PulseAudio RDP // pipes — cpal fails (no /proc/asound/cards) but arecord succeeds. if (process.platform === 'linux' && hasCommand('arecord')) { const probe = await probeArecord() if (probe.ok) { return { available: true, reason: null } } if (getPlatform() === 'wsl') { return { available: false, reason: wslNoAudioReason } } logForDebugging(`[voice] arecord probe failed: ${probe.stderr}`) // fall through to SoX }
// Fallback: check for SoX if (!hasCommand('rec')) { // WSL without arecord AND without SoX: the generic "install SoX" // hint below is misleading on WSL1/Win10 (no audio devices at all), // but correct on WSL2+WSLg (SoX works via PulseAudio). Since we can't // distinguish WSLg-vs-not without a backend to probe, show the WSLg // guidance — it points WSL1 users at native Windows AND tells WSLg // users their setup should work (they can install sox or alsa-utils). // Known gap: WSL with SoX but NO arecord skips both this branch and // the probe above — hasCommand('rec') lies the same way. We optimistically // trust it (WSLg+SoX would work) rather than probeSox() for a near-zero // population (WSL1 × minimal distro × SoX-but-not-alsa-utils). if (getPlatform() === 'wsl') { return { available: false, reason: wslNoAudioReason } } const pm = detectPackageManager() return { available: false, reason: pm ? `Voice mode requires SoX for audio recording. Install it with: ${pm.displayCommand}` : 'Voice mode requires SoX for audio recording. Install SoX manually:\n macOS: brew install sox\n Ubuntu/Debian: sudo apt-get install sox\n Fedora: sudo dnf install sox', } }
return { available: true, reason: null }}
// ─── Recording (native audio on macOS/Linux/Windows, SoX/arecord fallback on Linux) ─────────────
let activeRecorder: ChildProcess | null = nulllet nativeRecordingActive = false
export async function startRecording( onData: (chunk: Buffer) => void, onEnd: () => void, options?: { silenceDetection?: boolean },): Promise<boolean> { logForDebugging(`[voice] startRecording called, platform=${process.platform}`)
// Try native audio module first (macOS, Linux, Windows via cpal) const napi = await loadAudioNapi() const nativeAvailable = napi.isNativeAudioAvailable() && (process.platform !== 'linux' || (await linuxHasAlsaCards())) const useSilenceDetection = options?.silenceDetection !== false if (nativeAvailable) { // Ensure any previous recording is fully stopped if (nativeRecordingActive || napi.isNativeRecordingActive()) { napi.stopNativeRecording() nativeRecordingActive = false } const started = napi.startNativeRecording( (data: Buffer) => { onData(data) }, () => { if (useSilenceDetection) { nativeRecordingActive = false onEnd() } // In push-to-talk mode, ignore the native module's silence-triggered // onEnd. Recording continues until the caller explicitly calls // stopRecording() (e.g. when the user presses Ctrl+X). }, ) if (started) { nativeRecordingActive = true return true } // Native recording failed — fall through to platform fallbacks }
// Windows has no supported fallback if (process.platform === 'win32') { logForDebugging('[voice] Windows native recording unavailable, no fallback') return false }
// On Linux, try arecord (ALSA utils) before SoX. Consult the probe so // backend selection matches checkRecordingAvailability() — otherwise // on headless Linux with both alsa-utils and SoX, the availability // check falls through to SoX (probe.ok=false, not WSL) but this path // would still pick broken arecord. Probe is memoized; zero latency. if ( process.platform === 'linux' && hasCommand('arecord') && (await probeArecord()).ok ) { return startArecordRecording(onData, onEnd) }
// Fallback: SoX rec (Linux, or macOS if native module unavailable) return startSoxRecording(onData, onEnd, options)}
function startSoxRecording( onData: (chunk: Buffer) => void, onEnd: () => void, options?: { silenceDetection?: boolean },): boolean { const useSilenceDetection = options?.silenceDetection !== false
// Record raw PCM: 16 kHz, 16-bit signed, mono, to stdout. // --buffer 1024 forces SoX to flush audio in small chunks instead of // accumulating data in its internal buffer. Without this, SoX may buffer // several seconds of audio before writing anything to stdout when piped, // causing zero data flow until the process exits. const args = [ '-q', // quiet '--buffer', '1024', '-t', 'raw', '-r', String(RECORDING_SAMPLE_RATE), '-e', 'signed', '-b', '16', '-c', String(RECORDING_CHANNELS), '-', // stdout ]
// Add silence detection filter (auto-stop on silence). // Omit for push-to-talk where the user manually controls start/stop. if (useSilenceDetection) { args.push( 'silence', // start/stop on silence '1', '0.1', SILENCE_THRESHOLD, '1', SILENCE_DURATION_SECS, SILENCE_THRESHOLD, ) }
const child = spawn('rec', args, { stdio: ['pipe', 'pipe', 'pipe'], })
activeRecorder = child
child.stdout?.on('data', (chunk: Buffer) => { onData(chunk) })
// Consume stderr to prevent backpressure child.stderr?.on('data', () => {})
child.on('close', () => { activeRecorder = null onEnd() })
child.on('error', err => { logError(err) activeRecorder = null onEnd() })
return true}
function startArecordRecording( onData: (chunk: Buffer) => void, onEnd: () => void,): boolean { // Record raw PCM: 16 kHz, 16-bit signed little-endian, mono, to stdout. // arecord does not support built-in silence detection, so this backend // is best suited for push-to-talk (silenceDetection: false). const args = [ '-f', 'S16_LE', // signed 16-bit little-endian '-r', String(RECORDING_SAMPLE_RATE), '-c', String(RECORDING_CHANNELS), '-t', 'raw', // raw PCM, no WAV header '-q', // quiet — no progress output '-', // write to stdout ]
const child = spawn('arecord', args, { stdio: ['pipe', 'pipe', 'pipe'], })
activeRecorder = child
child.stdout?.on('data', (chunk: Buffer) => { onData(chunk) })
// Consume stderr to prevent backpressure child.stderr?.on('data', () => {})
child.on('close', () => { activeRecorder = null onEnd() })
child.on('error', err => { logError(err) activeRecorder = null onEnd() })
return true}
export function stopRecording(): void { if (nativeRecordingActive && audioNapi) { audioNapi.stopNativeRecording() nativeRecordingActive = false return } if (activeRecorder) { activeRecorder.kill('SIGTERM') activeRecorder = null }}