fix(voice): add SoX fallback for /voice on Windows when native audio module is unavailable
Problem
/voice fails on Windows with:
Voice recording requires the native audio module, which could not be loaded.
The native audio-capture.node binary is not bundled with the Windows standalone binary (claude.exe). The v2.1.70 changelog mentions "Fixed voice mode failing on Windows native binary with 'native audio module could not be loaded'" but the issue persists in v2.1.71 — the .node path was corrected, but the binary itself still isn't shipped.
On Linux, the code gracefully falls back to arecord (ALSA) or rec (SoX).
On macOS, it falls back to rec (SoX).
On Windows, there is no fallback — it immediately returns an error.
Environment: Windows 10 Enterprise 10.0.19045, Claude Code v2.1.71
Root Cause
Three functions short-circuit on win32 before reaching the SoX fallback path:
checkRecordingAvailability
if (process.platform === "win32")
return { available: false, reason: "Voice recording requires the native audio module..." };
// SoX check is never reached on Windows
checkVoiceDependencies
if (process.platform === "win32")
return { available: false, missing: [...], installCommand: null };
// SoX check is never reached on Windows
startRecording
if (process.platform === "win32")
return log("[voice] Windows native recording unavailable, no fallback"), false;
// rec/arecord fallbacks are never reached on Windows
Additionally, getInstallCommand has no Windows package manager entries.
Proposed Fix
SoX is fully cross-platform and available on Windows via winget install ChrisBagwell.SoX. However, there's one key difference: Windows SoX only ships sox.exe — there is no rec symlink. The rec command is equivalent to sox -d (record from default audio device).
1. checkRecordingAvailability — check for sox on Windows
// Remove the early win32 rejection.
// After the arecord check, add a Windows-specific branch:
if (process.platform === "win32") {
if (!commandExists("sox")) {
const install = getInstallCommand();
return {
available: false,
reason: install
? `Voice mode requires SoX for audio recording. Install it with: ${install.displayCommand}`
: `Voice mode requires SoX for audio recording.\n Install: winget install ChrisBagwell.SoX`,
};
}
return { available: true, reason: null };
}
2. checkVoiceDependencies — check sox instead of rec on Windows
// Remove the early win32 rejection.
// Replace the single rec check with platform-aware logic:
const missing: string[] = [];
if (process.platform === "win32") {
if (!commandExists("sox")) missing.push("sox");
} else if (!commandExists("rec")) {
missing.push("sox (rec command)");
}
3. startRecording — add startSoxWin32() fallback
// Remove the early win32 return. Route to a new Windows function:
if (process.platform === "linux" && commandExists("arecord"))
return startArecord(onData, onClose);
if (process.platform === "win32")
return startSoxWin32(onData, onClose, options); // NEW
return startRec(onData, onClose, options);
New function — identical to startRec but uses sox -d instead of rec:
function startSoxWin32(
onData: (data: Buffer) => void,
onClose: () => void,
options?: { silenceDetection?: boolean },
): boolean {
const silenceDetection = options?.silenceDetection !== false;
const args = [
"-d", // default audio input device (equivalent of rec)
"-q", "--buffer", "1024",
"-t", "raw",
"-r", String(SAMPLE_RATE),
"-e", "signed", "-b", "16",
"-c", String(CHANNELS),
"-", // output to stdout
];
if (silenceDetection)
args.push("silence", "1", "0.1", SILENCE_THRESHOLD, "1", SILENCE_DURATION, SILENCE_THRESHOLD);
const proc = spawn("sox", args, { stdio: ["pipe", "pipe", "pipe"] });
childProcess = proc;
proc.stdout?.on("data", (chunk) => onData(chunk));
proc.stderr?.on("data", () => {});
proc.on("close", () => { childProcess = null; onClose(); });
proc.on("error", (err) => { reportError(err); childProcess = null; onClose(); });
return true;
}
4. getInstallCommand — add Windows package managers
// After the Linux block:
if (process.platform === "win32") {
if (commandExists("winget"))
return {
cmd: "winget",
args: ["install", "--id", "ChrisBagwell.SoX", "-e"],
displayCommand: "winget install ChrisBagwell.SoX",
};
if (commandExists("choco"))
return {
cmd: "choco",
args: ["install", "sox.portable", "-y"],
displayCommand: "choco install sox.portable",
};
if (commandExists("scoop"))
return {
cmd: "scoop",
args: ["install", "sox"],
displayCommand: "scoop install sox",
};
}
Why sox -d Instead of rec
| Platform | SoX ships rec? | Record command |
|----------|:-:|---|
| Linux | Yes (symlink) | rec -q --buffer 1024 -t raw ... |
| macOS | Yes (symlink) | rec -q --buffer 1024 -t raw ... |
| Windows | No | sox -d -q --buffer 1024 -t raw ... |
rec is a symlink to sox that implicitly sets -d (default audio input). Since Windows doesn't create symlinks during SoX installation, we call sox -d directly — the behavior is identical.
Verification
Tested on Windows 10 Enterprise with SoX 14.4.2 installed via winget install ChrisBagwell.SoX:
> sox --version
sox: SoX v14.4.2
> sox -d -q --buffer 1024 -t raw -r 16000 -e signed -b 16 -c 1 output.raw
(records audio successfully from default microphone)
Output format matches exactly what the voice WebSocket expects: linear16, 16kHz, mono.
Compatibility
- No changes to existing Linux/macOS behavior — existing
rec/arecordpaths are untouched - Native module still preferred — SoX is only used when
audio-capture.nodefails to load - Graceful degradation — if SoX isn't installed, the user gets a clear install command for their package manager (winget/choco/scoop)
- Same audio format —
sox -doutputs identical PCM data torec
Related
- #30915 — original report of
audio-capture.nodepath hardcoded to CI build machine
This issue has 10 comments on GitHub. Read the full discussion on GitHub ↗