Claude Code Voice Over SSH
When Your Server Has No Microphone
بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ
Size
Spacing
Font
- The missing microphone
- Why it fails
- Why it’s fixable anyway
- What else could work
- The setup
- Step 1: open the local audio server to TCP
- Step 2: SSH in with a reverse tunnel
- Step 3: point the remote at the audio server
- Step 4: route ALSA through PulseAudio
- Step 5: test it
- The full picture
- Trade-offs
- Making it permanent
- Side effect: when the RemoteForward warning shows up
- Scenario 1: CI scripts inheriting the directive
- Fix: opt out of forwarding for scripts
- Scenario 2: a second interactive session to the same host
- Fix: SSH connection multiplexing
- Summary
The missing microphone
Claude Code’s voice mode needs a microphone. On a remote Linux server reached over SSH, there is none. Turn on voice mode there, and the audio stack throws:
cannot find card '0'The mic isn’t broken. Servers typically ship without audio hardware. The fix is to forward the local microphone over SSH so the remote process talks to it as if it were its own.
Why it fails
The error is in ALSA’s format: card '0' is the kernel-level audio device the layer expects to find. Whatever path voice mode takes to read the mic ends up at ALSA, which scans for a physical device, finds none, and returns the error above.
The userspace daemons that normally sit between an app and the hardware, PulseAudio and PipeWire, aren’t running on a typical server install either. Even if they were, they would have nothing to manage.
So the problem isn’t a misconfiguration. There’s no audio on the box at all.
Why it’s fixable anyway
The assumption that voice capture is hardware-local is what makes this look unsolvable. On Linux it is not: the daemon is a userspace service, and the hardware sits behind it. PipeWire and PulseAudio both expose the same wire protocol over a network socket, not just the local Unix socket. With the right config, an app on one machine can record from the mic on another.
That’s the whole approach: turn on TCP on the local daemon, forward the port through SSH, point the remote process at it.
What else could work
Audio over a network has several flavors in Linux. The neighbors of this approach:
- PipeWire’s RTP modules (
module-rtp-source/module-rtp-sink). Stream raw PCM, Opus, or MIDI over UDP. More typically used for LAN multicast (default group224.0.0.56) than point-to-point, lower-latency potential than TCP, no built-in encryption, both ends need matching address and format config. - PulseAudio’s
module-tunnel-source. The remote loads this module pointing at the local machine’s PulseAudio server (or PipeWire’s Pulse compatibility layer). Same native protocol, no SSH tunnel involved. That is also the cost: no encryption, and the auth cookie travels in the clear over the network. - X11 forwarding. Often suggested in audio-over-SSH discussions, but
ssh -Xcarries no audio. Setups that bolt audio onto X still need a graphical session and a separate audio transport: PulseAudio’smodule-x11-publish(which uses X11 properties to advertise the PA server address), or NX/x2go (which add a separate audio channel alongside an X session). None of that applies on a headless server. - Just running Claude Code locally. Always an option. The cost is giving up the remote machine’s resources, reachability from anywhere, and infrastructure under your control.
The TCP-over-SSH path picked here adds nothing new on either side (PipeWire is already on the laptop, OpenSSH is on both), and it’s encrypted by virtue of the SSH tunnel.
The setup
Three moving parts:
- The local audio daemon listens on a TCP port, in addition to its usual Unix socket.
- SSH carries that port to the remote machine through a reverse tunnel.
- The remote audio stack (both PulseAudio-aware apps and ALSA-only ones) is pointed at the tunnel.
Step 1: open the local audio server to TCP
On your local machine, copy the default PipeWire PulseAudio config to your user directory:
cp /usr/share/pipewire/pipewire-pulse.conf ~/.config/pipewire/pipewire-pulse.confOpen ~/.config/pipewire/pipewire-pulse.conf, find the server.address block, and add the TCP address:
server.address = [ "unix:native" "tcp:127.0.0.1:4713" # localhost only, for SSH audio forwarding]Restart PipeWire to apply:
systemctl --user restart pipewire pipewire-pulsePipeWire now listens on TCP port 4713, but only on localhost. It is not reachable from the network. The change is in the config file, so it persists across reboots.
Step 2: SSH in with a reverse tunnel
Instead of a plain ssh user@server, add the -R flag:
ssh -R 4713:127.0.0.1:4713 user@your-serverThe -R flag tells SSH: anything that connects to port 4713 on the remote machine should be forwarded to port 4713 on the local machine. The audio traffic travels inside the encrypted SSH connection.
Step 3: point the remote at the audio server
Once logged in on the remote, set this environment variable:
export PULSE_SERVER="tcp:127.0.0.1:4713"Any app that uses PulseAudio (or PipeWire’s Pulse compatibility layer) will now connect to that port, which tunnels straight back to the local mic.
Step 4: route ALSA through PulseAudio
Some apps reach ALSA directly rather than going through the PulseAudio environment variables. Claude Code’s voice mode is one of them, judging by the original error format (cannot find card '0' is an ALSA message). For those apps, ALSA needs its own routing rule. Create ~/.asoundrc on the remote machine:
pcm.!default { type pulse }ctl.!default { type pulse }This tells ALSA: when an app asks for the default audio device, route it through PulseAudio instead of looking for hardware. Install the required packages:
sudo apt install libasound2-plugins pulseaudio-utils alsa-utilsalsa-utils provides arecord and the recording-side ALSA binaries the voice capture path needs. Without the package, the audio path can be fully working (tunnel up, mic visible from pactl info) and voice mode will still fail because no recording binary exists to read from the source. The kind of gap that hides quietly: every layer of the stack tests fine on its own, but the thing that actually reads bytes off the mic is missing. Installing the package was what restored voice mode in this setup; for anyone hitting the same dead end, alsa-utils will inshallah close the gap. The closed-source voice client probably calls arecord or one of its siblings, but the strict claim is just that alsa-utils was the missing piece.
Step 5: test it
Still on the remote, run:
pactl infoIf everything works, the output shows the local machine’s audio server info, including the local microphone listed as the default source. That’s the confirmation.
The full picture
End-to-end, the audio path is:
%%{init: {"flowchart": {"useMaxWidth": false}} }%%
graph TD
A["Claude Code (remote)"] --> B["tcp:127.0.0.1:4713 on remote"]
B --> C["SSH reverse tunnel"]
C --> D["local PipeWire on port 4713"]
D --> E["physical microphone"] The voice goes from hardware → PipeWire → TCP socket → SSH tunnel → remote server → Claude Code. The remote machine never needs its own audio hardware.
Trade-offs
What this costs:
- Latency. The mic stream goes through PipeWire, a TCP socket, an SSH encryption pass, and back. On a home connection to a nearby server it isn’t noticeable for dictation; on a slow link or a faraway server, it will be.
- Bandwidth. The stream is uncompressed PCM. Voice mode is short bursts, so the volume is small in practice, but a tethered or capped link will feel it.
- Trust scope. The remote process gets your live mic for as long as the SSH session is open. Anything else running on the remote machine, under the same user, can read from the same source while the tunnel is up. That is normal for any forwarded device, but worth being aware of.
- Single-machine assumption. The setup binds to localhost on the remote, which is what makes it safe. It also means only processes on that one remote machine can use the mic, not a second machine reached through the same hop.
Making it permanent
The setup above works for one session. To make it permanent:
On the remote. PULSE_SERVER should be in ~/.zshrc (or ~/.bashrc):
echo "export PULSE_SERVER='tcp:127.0.0.1:4713'" >> ~/.zshrcFor SSH. Add RemoteForward to ~/.ssh/config on the local machine, so the -R flag isn’t needed every time:
Host your-server HostName your-server-ip User your-user RemoteForward 4713 127.0.0.1:4713A plain ssh your-server now sets up the tunnel automatically.
For the local TCP module. Already handled in Step 1. Port 4713 opens on every login.
Side effect: when the RemoteForward warning shows up
Once RemoteForward 4713 127.0.0.1:4713 lives in ~/.ssh/config, every SSH connection to that host tries to set up the audio forwarding. There are two situations where it cannot, and both produce the same warning:
Warning: remote port forwarding failed for listen port 4713The connection still goes through (the deploy completes, the shell opens normally), but the warning looks alarming. Two distinct scenarios share the symptom.
Scenario 1: CI scripts inheriting the directive
Woodpecker CI runs on the local machine. A lightweight, self-hosted CI system that picks up git pushes via webhook and runs build steps directly on the host (no containers). The deploy step uses ssh and rsync to push the built site to a remote server. Both commands connect to the same host alias that now has RemoteForward 4713 in its SSH config.
Every deploy started printing the warning. The deploy itself worked fine: rsync completed, the symlink swapped, the site went live. In a CI log, that kind of warning is the type that makes you stop and investigate, even when there is nothing wrong.
The CI process runs in an environment without the local user’s audio runtime: different user, no interactive shell, the local PipeWire socket not reachable from where the CI agent runs. The forwarding has nothing to land on, so SSH gives up and prints the warning.
Fix: opt out of forwarding for scripts
SSH has ClearAllForwardings, which tells it to ignore all LocalForward and RemoteForward directives from the config file. Set it per-command with -o:
ssh -o ClearAllForwardings=yes g12 "mkdir -p /some/path"For a deploy script with multiple SSH calls, define it once at the top:
SSH="ssh -o ClearAllForwardings=yes"export RSYNC_RSH="$SSH"RSYNC_RSH tells rsync which SSH command to use. Every $SSH call and every rsync transfer in the script now connects without attempting any port forwarding. The warning disappears. Interactive ssh g12 sessions still get the audio tunnel because they do not use this override.
Scenario 2: a second interactive session to the same host
The other case shows up when two terminals (or the same terminal at two times) both ssh g12. The first session sets up the RemoteForward and binds port 4713 on the remote. The second session reads the same config, tries to bind 4713 on the remote, and finds it taken.
The warning appears on the second session. Voice mode still works in the second session, surprisingly: the tunnel from the first session is shared transparently because the bound port routes back to the same local PipeWire. The audio flows. The warning misleads: it implies the forward failed for everything, when only this specific session’s attempt to also bind it failed.
The real consequence is hidden: if the first session ever closes, the tunnel dies for both. The second session looks like it is still connected, but voice mode stops working until a fresh first session is opened.
Fix: SSH connection multiplexing
OpenSSH has built-in connection multiplexing via ControlMaster. The first session opens a master connection; subsequent sessions to the same host share it instead of creating a new TCP connection. There is only one RemoteForward, only one tunnel, no warnings on later sessions.
Add to the Host block in ~/.ssh/config:
Host your-server HostName your-server-ip User your-user RemoteForward 4713 127.0.0.1:4713 ControlMaster auto ControlPath ~/.ssh/cm_%r@%h:%p ControlPersist 10mControlMaster auto enables sharing. ControlPath is where the multiplexed-connection socket file lives; %r, %h, %p expand to user, host, port. ControlPersist 10m keeps the master connection alive ten minutes after the last session closes, so opening a new terminal does not pay the SSH-handshake cost again.
With this in place, any number of ssh g12 terminals can be opened in parallel: one tunnel under the hood, no warnings on later sessions, voice mode will inshallah be reachable from any of them. Closing one terminal does not pull the rug out from the others. This applies the standard OpenSSH multiplexing pattern to the audio-tunnel scenario; the underlying mechanism is well-documented, but worth verifying in your own setup before relying on it.
A clean separation across both scenarios: scripts opt out of forwarding, interactive sessions share one tunnel.
Summary
| What | Where | Command |
|---|---|---|
| TCP module (permanent) | Local (~/.config/pipewire/pipewire-pulse.conf) | Add "tcp:127.0.0.1:4713" to server.address |
| SSH tunnel (permanent) | Local (~/.ssh/config) | RemoteForward 4713 127.0.0.1:4713 under Host your-server |
| Multi-session multiplexing | Local (~/.ssh/config) | ControlMaster auto + ControlPath ~/.ssh/cm_%r@%h:%p + ControlPersist 10m |
| Set audio server | Remote (~/.zshrc) | export PULSE_SERVER='tcp:127.0.0.1:4713' |
| Fix ALSA | Remote (~/.asoundrc) | pcm.!default { type pulse } |
| Install packages | Remote | sudo apt install libasound2-plugins pulseaudio-utils alsa-utils |
| CI/scripts override | Deploy scripts | ssh -o ClearAllForwardings=yes + RSYNC_RSH |
Five steps for the audio setup, one block for multiplexing, one override for scripts that shouldn’t carry the forwarding at all. The pieces are already in the box: PipeWire, OpenSSH, ALSA. The work is getting them to point at each other.
AI Editors 1 of 1
Back to AI EditorsAerospace engineer
Ethical entrepreneur in public
You handle your business
I handle the digital side
Work with me
- AI with honesty
- Private infrastructure
- Websites that perform
Tell me about your situation:
javed@javedab.com More about me