Claude Code Voice Over SSH

When Your Server Has No Microphone

08.04.2026 | 20 Shawwal 1447
10 min read

بِسْمِ ٱللَّهِ ٱلرَّحْمَـٰنِ ٱلرَّحِيمِ

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:

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:

  1. The local audio daemon listens on a TCP port, in addition to its usual Unix socket.
  2. SSH carries that port to the remote machine through a reverse tunnel.
  3. 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:

Terminal window
cp /usr/share/pipewire/pipewire-pulse.conf ~/.config/pipewire/pipewire-pulse.conf

Open ~/.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:

Terminal window
systemctl --user restart pipewire pipewire-pulse

PipeWire 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.

external link docs.pipewire.org/page_module_protocol_pulse.html

Step 2: SSH in with a reverse tunnel

Instead of a plain ssh user@server, add the -R flag:

Terminal window
ssh -R 4713:127.0.0.1:4713 user@your-server

The -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.

external link man.openbsd.org/ssh_config.5

Step 3: point the remote at the audio server

Once logged in on the remote, set this environment variable:

Terminal window
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:

Terminal window
sudo apt install libasound2-plugins pulseaudio-utils alsa-utils

alsa-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.

external link www.freedesktop.org/wiki/Software/PulseAudio/Documentation/User/PerfectSetup

Step 5: test it

Still on the remote, run:

Terminal window
pactl info

If 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:

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):

Terminal window
echo "export PULSE_SERVER='tcp:127.0.0.1:4713'" >> ~/.zshrc

For 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:4713

A 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 4713

The 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:

Terminal window
ssh -o ClearAllForwardings=yes g12 "mkdir -p /some/path"

For a deploy script with multiple SSH calls, define it once at the top:

Terminal window
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.

external link man.openbsd.org/ssh_config.5

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 10m

ControlMaster 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.

external link man.openbsd.org/ssh_config.5

A clean separation across both scenarios: scripts opt out of forwarding, interactive sessions share one tunnel.

Summary

WhatWhereCommand
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 multiplexingLocal (~/.ssh/config)ControlMaster auto + ControlPath ~/.ssh/cm_%r@%h:%p + ControlPersist 10m
Set audio serverRemote (~/.zshrc)export PULSE_SERVER='tcp:127.0.0.1:4713'
Fix ALSARemote (~/.asoundrc)pcm.!default { type pulse }
Install packagesRemotesudo apt install libasound2-plugins pulseaudio-utils alsa-utils
CI/scripts overrideDeploy scriptsssh -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.

Aerospace 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