Skip to content

Audio capture and voice mode

This page uses reverse-engineered cli.renamed.js and shim anchors to answer the voice question: does Claude Code support voice, and how is it designed?

Yes. The runtime contains a source-confirmed voice dictation path. It is local microphone capture plus remote transcription: /voice enables hold-to-talk or tap-to-toggle recording, one capture backend is selected before recording, a WebSocket client sends 16-bit mono PCM to the voice service, and final transcript text is passed back to the normal input editor.

Source anchors

Semantic aliasString or symbolMeaning
AudioNativeAddonRequirerequire("/$bunfs/root/audio-capture.node")Main bundle can load the embedded audio native addon.
AudioCaptureShimrequire("/$bunfs/root/audio-capture.node")Retained JS shim references the audio N-API binary from the original Bun payload.
LegacyVoiceEnabledSettingvoiceEnabled:y.booleanLegacy/global voice setting surface.
VoiceModeSettingsSchemaVoice mode settings (hold-to-talk / tap-to-toggle dictation)Structured voice settings schema.
VoiceLanguageSettingPreferred language for Claude responses and voice dictationVoice dictation shares the language setting.
VoiceIdleStatevoiceState:"idle"TUI voice state starts idle.
VoiceInterimTranscriptStatevoiceInterimTranscriptInterim transcript is part of TUI state.
VoiceAudioLevelsStatevoiceAudioLevelsAudio-level visualization state.
PushToTalkActionvoice:pushToTalkKeybinding/action for voice recording.
NativeAudioWrapperwriteNativePlaybackData:()=>writeNativePlaybackDataNative audio wrapper exports playback and recording methods.
AudioNapiLoadedLogaudio-capture-napi loadedNative audio addon load path succeeds when available.
MicrophoneAccessGuardVoice mode requires microphone accessRemote/no-device guard for voice mode.
WslRecorderFallbackGuardVoice mode could not find a working audio recorder in WSLWSL fallback error.
SoxRecorderRequirementVoice mode requires SoX for audio recordingSoX fallback requirement.
VoiceAccountGateVoice mode requires a Claude.ai accountVoice stream is account-gated.
VoiceSlashCommandToggle voice mode/voice slash command description.
VoiceTuiComponentsVoiceIndicator, VoiceWarmupHintTUI components render recording/warmup state.
VoiceFinishRecordingfinishRecording: stopping recordingRecording state machine finalizes capture.
VoiceWebSocketBufferingstartRecording: buffering audio while WebSocket connectsCaptured chunks are buffered until the transcription stream is ready.
VoiceFinalTranscriptFinal transcript assembledTranscription stream produces final text.
VoiceTranscriptInjectionInjecting transcriptFinal transcript is injected into the input buffer.
VoiceConnectionFailureTelemetryvoice_transcription_connection_failedVoice stream connection failure telemetry.
VoiceStreamErrorPathvoice_stream errorVoice stream error path.
VoiceStreamAuthFailurevoice_stream_no_authVoice stream auth failure telemetry.
VoiceDiscoveryHintUse /voice to enable push-to-talk dictationUser-visible discovery hint.

High-level design

flowchart TD
User["/voice command or push-to-talk key"] --> Settings[voice.enabled / voice.mode / autoSubmit]
Settings --> UI[VoiceProvider state]
UI --> Capture{capture backend}
Capture -->|native available| Native[audio-capture.node N-API]
Capture -->|Linux/WSL fallback| Recorder[arecord or SoX rec]
Native --> Chunks[audio chunks + audio levels]
Recorder --> Chunks
Chunks --> Stream[voice transcription stream]
Stream --> Interim[interim transcript]
Stream --> Final[final transcript]
Final --> Inject[input buffer injection]
Inject --> Prompt[normal prompt submission path]

Voice mode is dictation, not a separate agent loop. After transcription, the result flows back into the same text prompt pipeline used by keyboard input.

User-facing controls

SurfaceMeaning
/voiceToggle or configure voice mode.
/voice holdHold-to-talk dictation.
/voice tapTap-to-toggle dictation.
/voice offDisable voice mode.
voice:pushToTalkTUI keybinding/action; the default chat binding includes space for push-to-talk.
voice.autoSubmitSetting that can submit after transcript injection rather than only inserting text.
languagePreferred language for Claude responses and voice dictation.

The state model contains voiceState, voiceError, voiceInterimTranscript, voiceAudioLevels, voiceWarmingUp, and awaitingVoiceSubmitDoubleTap, which explains the visible warmup/recording/transcribing feedback.

Capture backends

Native N-API path

The original Bun payload ships audio-capture.js and audio-capture.node; the local extraction pipeline writes the .node binary alongside the shim (*.node is gitignored). The artifact-specific ABI evidence—ALSA linkage, imported PCM symbols, and the observed N-API export list—lives in Audio capture native module. Those surfaces do not reveal the addon’s internal thread, recovery, or cleanup order.

cli.renamed.js loads the native addon when it is available at runtime and exports wrappers such as:

  • startNativeRecording
  • stopNativeRecording
  • isNativeRecordingActive
  • startNativePlayback
  • writeNativePlaybackData
  • stopNativePlayback
  • microphoneAuthorizationStatus
  • isNativeAudioAvailable

The audio-capture-napi loaded anchor confirms the runtime attempts to use this addon when available.

Recorder fallback path

When native capture is unavailable, the runtime falls back to command-line recorders:

  • Linux/WSL can use arecord.
  • A SoX rec path exists and produces user-facing guidance when missing.
  • WSL has explicit failure messaging when no working recorder exists.

This selection design keeps the JS/TUI voice state machine independent from any one capture backend. The readable path selects a backend before recording; it does not establish that a native error after startup automatically transfers the same recording to arecord or SoX.

Transcription stream and injection

The recording flow has two phases:

  1. Capture phase: start local recording, collect chunks, and surface audio levels.
  2. Stream phase: connect to the voice stream, buffer audio while the WebSocket-like stream is connecting, send audio frames, receive interim/final transcript messages, and close/finalize.
sequenceDiagram
autonumber
participant UI as TUI voice action
participant Capture as Native/fallback capture
participant Buffer as Audio buffer
participant Stream as Voice stream
participant Input as Prompt input buffer
UI->>Capture: startRecording
Capture-->>UI: audio levels / chunks
UI->>Stream: open transcription stream
Capture->>Buffer: buffer chunks while stream connects
Stream-->>UI: ready
Buffer->>Stream: flush buffered chunks
Capture->>Stream: send live audio frames
Stream-->>UI: interim transcript
UI->>Capture: finishRecording
Capture-->>Stream: final audio / close
Stream-->>UI: final transcript
UI->>Input: Injecting transcript

The source strings Final transcript assembled and Injecting transcript confirm that the transcribed text is not merely displayed; it becomes input to the regular prompt flow.

Wire contract and buffering

connectVoiceStream() constructs the client protocol directly in readable source:

FieldValue or behavior
Path/api/ws/speech_to_text/voice_stream on the configured Claude API WebSocket origin (or VOICE_STREAM_BASE_URL).
Audio queryencoding=linear16, sample_rate=16000, channels=1.
Endpointing queryendpointing_ms=300, utterance_end_ms=1000.
Other query stateLanguage, use_conversation_engine=true, stt_provider=deepgram-nova3, and optional typed interim forwarding.
AuthenticationOAuth bearer token plus CLI client headers; sanitized key terms can be sent in x-config-keyterms.
Keepalive{"type":"KeepAlive"} immediately after open and every 8 seconds.
Finalization{"type":"CloseStream"}; finalization also has 1.5-second no-data and 5-second safety timeouts.
Server frames handledTranscriptInterim, TranscriptText, TranscriptEndpoint, TranscriptError, and generic error.

Capture starts while the socket connects. The JS layer queues chunks, then coalesces them into frames of at most 32,000 bytes when onReady fires. Live chunks are sent directly after that. Audio arriving after CloseStream is dropped.

The resilience behavior is bounded rather than an open-ended reconnect loop:

  • an error before any transcript can trigger one early stream retry after 250 ms;
  • a completed recording that had audio and a connected socket but timed out with no text can replay its saved chunks once over a fresh connection;
  • replay uses the same 32,000-byte coalescing limit;
  • a mid-stream error preserves already accumulated final text before cleanup;
  • an unreported interim is promoted to final on endpoint, close, error, or finalization resolution where applicable.

Availability and gates

Voice mode is constrained by environment and account state:

GateSource-confirmed behavior
Local audio deviceRemote/no-device environments show Voice mode requires microphone access... run Claude Code locally instead.
Account/auth/voice can report Voice mode requires a Claude.ai account; stream errors include voice_stream_no_auth.
Recorder dependenciesWSL and SoX-specific errors guide the user when no recorder backend is available.
SettingsvoiceEnabled, voice.enabled, voice.mode, voice.autoSubmit, and language all affect behavior.
Feature/availability checkThe TUI renders voice indicators only when the availability helper says voice can run.

The current evidence supports documenting voice as supported local dictation, not as always available in every environment.

Telemetry and error handling

The bundle contains voice-specific telemetry/error names:

Event/stringMeaning
tengu_voice_toggledVoice setting changed.
tengu_voice_recording_startedLocal recording began.
tengu_voice_recording_completedRecording completed.
voice_transcription_connection_failedCould not connect to the transcription stream.
voice_transcription_no_audio_signalCapture produced no usable audio signal.
voice_transcription_no_speechSpeech was not detected in the recorded audio.
voice_stream_no_authVoice stream rejected/failed auth.
voice_stream errorGeneral stream failure.

Failures are surfaced in the TUI as voiceError and do not replace the normal text-input path.

Recording chain and dependency probe

The voice recorder picks one of three backends at start time. Selection lives in startRecording (cli.renamed.js line 611897) and prefers the embedded N-API addon when both the addon and a sound card are present, then falls back to arecord, then to SoX:

  1. audio-capture.node (N-API)RP8() lazy-loads audio-capture-napi from ./vendor/audio-capture/${arch-platform}/audio-capture.node (also probed at ../audio-capture/...), caching the binding on LG$ and emitting [voice] audio-capture-napi loaded in Xms. Selected when isNativeAudioAvailable() is true and /proc/asound/cards exists with at least one card.
  2. arecord (ALSA) — used when arecord --version succeeds and arecord -f S16_LE -r MQ6 -c wQ6 -t raw /dev/null returns a clean stderr within 150 ms (ON4() is cached in fQ6 after first probe).
  3. rec (SoX) — spawned with [-q, --buffer 1024, -t raw, -r MQ6, -e signed, -b 16, -c wQ6, -] and, when silenceDetection !== false, the trailing silence 1 0.1 YN4 1 Yb5 YN4 arguments so the recorder stops after detected silence.

checkRecordingAvailability() runs the same chain in dry-run mode before voice mode is unlocked. The diagnostic messages drive UX strings:

EnvironmentResulting reason
Remote / CLAUDE_CODE_REMOTE set"Voice mode requires microphone access, but no audio device is available in this environment. To use voice mode, run Claude Code locally instead."
WSL without working ALSA/SoX"Voice mode could not find a working audio recorder in WSL..." plus the sudo apt install sox libsox-fmt-pulse install hint
Generic Linux missing SoXinstall command resolved by MN4() from the first detected package manager

MN4() (the install-command picker) probes apt-get, dnf, then pacman in that order with WJH (3,000 ms --version execFile check), returning { cmd, args, displayCommand } so checkVoiceDependencies() can surface a single-line install hint like sudo apt-get install sox.

Permission gating runs through requestMicrophonePermission(): when the N-API addon is available it triggers a brief no-silence-detection recording and immediately stops it, which is intended to exercise an OS permission prompt where applicable; everywhere else the function returns true because the recorder process enforces access. microphoneAuthorizationStatus() exposes the addon’s reported numeric status (0 when the addon is missing). The inspected Linux-x64 addon returned 3 in a runtime probe; the native page records that observation without assigning a cross-platform semantic contract to the number.

Relationship to media native modules

The older Media native modules inventory correctly identified audio-capture.node as shipped payload. This page adds the missing runtime call path: the main bundle can load the module when present, starts/stops recording, falls back to OS recorders, and injects the resulting transcript.

Caveats

  • The .node binary itself is stripped. This page documents the JavaScript call boundary, exported wrapper names, and user-visible behavior, not native implementation details such as device enumeration internals.
  • The client endpoint, query parameters, and frame handling are readable. The server-side transcription implementation and the semantics of the named provider beyond the client parameter are not recoverable from this source alone.
  • Voice mode should be described as dictation. There is no evidence here that the agent loop itself becomes audio-native; text remains the prompt handoff after transcription.

Created and maintained by Yingting Huang.