pttman: System-wide push-to-talk for Linux
Contents
The Problem
Most push-to-talk implementations live inside individual apps. Discord has one, Teams has one, Zoom has a setting buried somewhere in its preferences. But if you want push-to-talk behavior everywhere on Linux, across every app that touches your microphone, you’re out of luck. There’s no system-level toggle that mutes and unmutes your mic on a key press/release in a way that actually works reliably.
The naive approach is a shell script that calls pactl set-source-mute on key
down and key up. That works until you press and release the key quickly, or hold
it and release while another press event is still in flight. PipeWire processes
those pactl calls concurrently, and the mute commands interleave: you end up
with a mic that’s stuck open or stuck muted, depending on which process finished
last.
What pttman Does
pttman↗ is a small Rust daemon that provides push-to-talk microphone control for PipeWire. It runs as a user service, listens on a Unix datagram socket, and serializes mute/unmute requests so that rapid key presses never race each other.
Three properties make it useful as a system-wide solution:
- It operates at the PipeWire/PulseAudio layer, so every app that uses your microphone sees the mute state change. No per-app configuration needed.
- By default, it controls all audio sources on the system. If you have a USB headset and a webcam mic, both get muted and unmuted together. You can also lock it to a single source if you prefer.
- It reapplies the intended mute state after PipeWire source changes and quickly reverts accidental unmutes from other tools.
The client side is a fire-and-forget datagram send. When you press a key, a
lightweight client process sends "press" to the socket and exits. On release,
it sends "release". The daemon drains all pending messages and applies the
last one, so if events pile up during a rapid press/release, the final state is
always correct.
If the daemon isn’t running, client commands fall back to direct pactl
execution. You don’t lose functionality; you just lose the race-condition
protection.
Setup
Install with Cargo and start the service:
cargo install pttman
pttman install-service
systemctl --user start pttman.service
install-service detects your init system automatically. It supports systemd
(user service) and OpenRC (user service on 0.60+, system service on older
versions).
By default, pttman controls all audio sources. To restrict it to one:
pttman list-sources
pttman set-default-source alsa_input.usb-046d_BRIO-03.pro-input-0
set-default-source writes to ~/.config/pttman.conf and signals the running
daemon to reload, so the change takes effect immediately.
Key Binding
You need something to send press/release events. I use xremap↗, which supports distinct actions for key press and key release:
modmap:
- name: Push-to-talk
remap:
F9:
skip_key_event: true
press: { launch: ["/home/your-user/.local/bin/pttman", "press"] }
release: { launch: ["/home/your-user/.local/bin/pttman", "release"] }
Pressing F9 unmutes, releasing it mutes. On some laptop keyboards,
F9 has a mic icon, which makes it a natural fit.
You can also route your compositor’s mic-mute key through pttman for a toggle
instead of push-to-talk. For example, in niri’s keybinds.kdl:
XF86AudioMicMute allow-when-locked=true { spawn "/home/your-user/.local/bin/pttman" "toggle"; }
How It Works
The daemon listens on $XDG_RUNTIME_DIR/pttman.sock and serializes commands
through a Unix datagram socket. For push-to-talk, press temporarily unmutes
and release temporarily mutes without changing the saved preference. When a
message arrives, it drains the socket non-blocking and coalesces: only the last
queued command executes. This means a burst of press, release, press, release
collapses to a single release action.
A background thread runs pactl subscribe to watch for audio source changes. If
you plug in a new USB mic while the daemon is running and you’re in all-sources
mode, pttman picks it up automatically and applies the current mute state to it.
Configuration changes are picked up via SIGHUP, which set-default-source
sends automatically.