Pi’s dictation shortcut (Ctrl+Alt+Z, via the pi-transcribe extension) records audio on the machine where pi runs. If pi runs on a headless server, there is no microphone — recording starts, but captures only silence. SSH can’t help: it forwards keystrokes, never audio.
The fix: tunnel your laptop’s microphone to the server as a virtual audio device. Both machines on a Tailscale network make the connection private by default1 .
pi (server) ← ALSA ← PipeWire ←── tunnel over Tailscale ──→ PulseAudio on laptop → laptop mic
Server setup (Ubuntu, one time)
- Install the audio stack (PipeWire server, its policy manager, and the ALSA/Pulse compatibility layers):
sudo apt install --no-install-recommends pipewire pipewire-pulse pipewire-alsa wireplumber
- Keep user services alive after you log out:
loginctl enable-linger
- Enable and start:
systemctl --user enable --now pipewire pipewire-pulse wireplumber
- Create the tunnel config at ~/.config/pipewire/pipewire.conf.d/10-tunnel-source.conf (replace the IP with your laptop’s Tailscale IP2 ):
context.modules = [
{ name = libpipewire-module-pulse-tunnel
flags = [ nofail ]
args = {
tunnel.mode = source
pulse.server.address = <LAPTOP_TAILSCALE_IP>:4713
stream.props = {
node.name = "remote_mic"
media.class = "Audio/Source"
}
}
}
]
Laptop setup
macOS
brew install pulseaudio
pulseaudio --load="module-native-protocol-tcp auth-ip-acl=100.64.0.0/10 listen=<LAPTOP_TAILSCALE_IP> port=4713" \
--exit-idle-time=-1 --daemon
- auth-ip-acl=100.64.0.0/10 restricts access to Tailscale-range addresses only3 .
- Approve the macOS microphone permission prompt when it appears — skipping it gives you perfect silence, the hardest failure to spot.
Linux
Even simpler — your desktop already runs PipeWire/PulseAudio, so just load the module:
pactl load-module module-native-protocol-tcp auth-ip-acl=100.64.0.0/10 listen=<LAPTOP_TAILSCALE_IP> port=4713
(If the server can’t connect, check the laptop firewall: sudo ufw allow in on tailscale0 to any port 4713.)
Connect and verify
The tunnel connects when the server’s PipeWire starts, so restart it once the laptop is serving. Skip this restart if the tunnel is already up and working — it tears the tunnel down and back up, and kills any dictation in flight:
systemctl --user restart pipewire
wpctl status | grep -A3 Sources
# ├─ Sources:
# │ * 36. (null) [vol: 1.00] ← working
The (null) name is normal for a tunnel source. The * means it’s the default — which is exactly what pi-transcribe’s system-default microphone uses. No pi configuration needed.
Quick end-to-end test — record 5 seconds and check it’s not silence4 :
pw-record --target remote_mic --rate 16000 /tmp/test.wav & PID=$!; sleep 5; kill $PID
python3 -c "
import wave, struct
w = wave.open('/tmp/test.wav'); raw = w.readframes(w.getnframes())
f = struct.unpack(f'<{len(raw)//2}h', raw[:len(raw)//2*2])
print('SILENT — check mic permission' if max(f) < 500 else 'real audio ✓')
"
Then press Ctrl+Alt+Z in pi, speak, press it again.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
PvRecorder failed to initialize | Tunnel source missing | Laptop serving? Then systemctl --user restart pipewire |
| Records but transcript is empty | macOS mic permission denied | System Settings → Privacy & Security → Microphone |
| Capture goes to the wrong device | Laptop’s PulseAudio default is not what you expect | pactl -s tcp:<LAPTOP_TAILSCALE_IP>:4713 list sources short, then set-default-source <n> |
| Services die after logout | Linger not enabled | loginctl enable-linger |