A virtual USB HID telephony headset for Linux that works with Zoom and Google Meet.
Creates a virtual USB headset device that:
- Appears as a physical headset to Zoom, Google Meet, and other conferencing apps
- Forwards audio from your real microphone through PipeWire
- Sends mute button events via USB HID telephony protocol
- Receives LED feedback from apps (mute/hook/ring indicators)
This allows you to control mute in Zoom/Meet using your keyboard, just like pressing a physical headset's mute button.
This project provides NixOS and Home Manager modules for easy integration.
- NixOS Module: See nixosModules/default.nix for setup and configuration options
- Home Manager Module: See homeManagerModules/default.nix for Waybar integration
# Using Nix flakes
just build
# Or with Cargo
cargo build --release --manifest-path packages/virtual-headset/Cargo.toml- Linux with kernel HID support (has been out since 2012)
- PipeWire audio system
If you are not using Nix integration, please also make sure you have:
pw-loopbackcommand (usually inpipewirepackage)pactlcommand (usually inpulseaudio-utilsorpipewire-pulsepackage)
The Nix package bundles these automatically.
The service starts automatically when you log in. Control it with:
systemctl --user status virtual-headset
# On *non-interactive* start (such as in systemd service), it uses what you
# have as "default" audio source in pipewire.
# After changing default audio source, restart this service so that it can
# use this as the new input.
systemctl --user restart virtual-headset-
Run the program:
virtual-headset # Or if built with cargo: ./target/release/virtual-headset -
Select your real microphone from the list
-
In Zoom/Meet, select "Virtual_Headset_Microphone" as your audio input
-
Press
mto toggle mute
m- Toggle mute (sends HID button event to apps)qorEsc- Quit and cleanupCtrl+C- Emergency quit
The virtual headset can be controlled via D-Bus or directly through HID reports.
The virtual-headset-ctl utility provides convenient commands:
mute- Mute microphone (via HID OUTPUT report)unmute- Unmute microphone (via HID OUTPUT report)toggle-mute- Toggle mute state (via HID OUTPUT report)mute-dbus- Mute via D-Busunmute-dbus- Unmute via D-Bustoggle-mute-dbus- Toggle via D-Busmonitor-mute- Monitor mute state changes with JSON output for Waybarrestart-service- Restart the systemd servicefind-device- Find the hidraw device path
- Service:
com.github.virtual_headset - Object Path:
/com/github/virtual_headset - Interface:
com.github.virtual_headset.Mute
Methods:
IsMuted() -> bool- Query current mute stateMute()- Mute if not already mutedUnmute()- Unmute if currently mutedToggle()- Toggle the mute state
Signals:
MuteChanged(bool muted)- Emitted whenever mute state changes
The device accepts control commands via HID OUTPUT reports (report ID 3):
- Write
[0x03, 0x01]to/dev/hidraw*to mute - Write
[0x03, 0x02]to/dev/hidraw*to unmute - Write
[0x03, 0x03]to/dev/hidraw*to toggle
The daemon receives these and sends INPUT reports to connected applications.
Toggle mute via HID:
# Recommended - sends HID OUTPUT report directly
virtual-headset-ctl toggle-mute
# Or manually to the hidraw device
echo -ne '\x03\x03' > /dev/hidraw0 # Replace hidraw0 with your deviceToggle mute via D-Bus:
# Using virtual-headset-ctl
virtual-headset-ctl toggle-mute-dbus
# Or directly with dbus-send
dbus-send --session --print-reply \
--dest=com.github.virtual_headset \
/com/github/virtual_headset \
com.github.virtual_headset.Mute.ToggleQuery current mute state:
# Via D-Bus
dbus-send --session --print-reply \
--dest=com.github.virtual_headset \
/com/github/virtual_headset \
com.github.virtual_headset.Mute.IsMutedMonitor for changes:
# Using virtual-headset-ctl (outputs JSON for Waybar)
virtual-headset-ctl monitor-mute
# Or directly with dbus-monitor
dbus-monitor --session \
"type='signal',interface='com.github.virtual_headset.Mute',member='MuteChanged'"For Waybar integration, see homeManagerModules/default.nix.
For other status bars, you can use virtual-headset-ctl commands:
i3status/i3blocks:
# Add to your i3blocks config:
[virtual-headset]
command=dbus-send --session --print-reply --dest=com.github.virtual_headset /com/github/virtual_headset com.github.virtual_headset.Mute.IsMuted | grep -q "boolean true" && echo "π" || echo "π"
interval=1
signal=10Polybar:
[module/virtual-headset]
type = custom/script
exec = dbus-send --session --print-reply --dest=com.github.virtual_headset /com/github/virtual_headset com.github.virtual_headset.Mute.IsMuted | grep -q "boolean true" && echo "π" || echo "π"
interval = 1
click-left = virtual-headset-ctl toggle-mute---
config:
theme: base
themeVariables:
primaryColor: "#1a0020"
primaryTextColor: "#fa99fa"
primaryBorderColor: "#aaaafa"
lineColor: "#888"
fontFamily: "monospace"
---
graph TB
RealMic[Real Microphone] -->|Audio| Daemon[virtual-headset daemon]
Daemon -->|Creates| VirtHID[Virtual HID Device<br/>/dev/hidraw]
Daemon -->|Creates & forwards audio| VirtMic[Virtual_Headset_Mic]
Daemon -->|Emits signals| DBus[D-Bus]
VirtMic -->|Audio input| Apps[Zoom / Meet]
VirtHID -->|HID Input Reports| Apps
Apps -->|HID Output Reports| VirtHID
How it works:
- Daemon forwards your real microphone audio to
Virtual_Headset_Micvia PipeWire - Daemon creates a virtual HID device that apps can connect to via WebHID
- Apps receive mute button events via HID Input Reports and send LED states via HID Output Reports
---
config:
theme: base
themeVariables:
primaryColor: "#1a0020"
primaryTextColor: "#fa99fa"
primaryBorderColor: "#aaaafa"
lineColor: "#888"
fontFamily: "monospace"
---
graph LR
CTL[virtual-headset-ctl]
VirtHID["Virtual HID Device<br/>/dev/hidraw*"]
Daemon[virtual-headset daemon]
DBus[D-Bus]
StatusBar[Status Bar / Waybar]
CTL -->|"HID Output Report ID 3<br/>mute/unmute/toggle"| VirtHID
VirtHID --> Daemon
Daemon -->|MuteChanged signal| DBus
DBus --> StatusBar
CTL -->|monitor-mute| DBus
Control flow:
virtual-headset-ctlsends control commands by writing HID Output Reports (ID 3) to/dev/hidraw*- Daemon receives these commands and sends HID Input Reports to connected apps
- Daemon emits D-Bus
MuteChangedsignals when state changes - Status bars monitor D-Bus signals to display current mute state
-
HID Device: Creates a virtual USB HID Telephony Headset via
/dev/uhid- Vendor ID:
0x0b0e(Jabra) - triggers kernel telephony driver - Product ID:
0x245e(Jabra Evolve2 65) - Device name:
"Virtual_Headset"- must match audio device name for Zoom
- Vendor ID:
-
HID Descriptor: Single Telephony collection with:
- INPUT Report (ID 1): Hook Switch (bit 0, Absolute) + Phone Mute (bit 1, Relative)
- OUTPUT Report (ID 2): Mute LED (bit 0) + Off-Hook LED (bit 1) + Ring LED (bit 2)
- OUTPUT Report (ID 3): Control commands (0x01=mute, 0x02=unmute, 0x03=toggle)
-
Audio Routing: Uses
pw-loopbackto create virtual microphone:pw-loopback \ --capture-props "target.object=<real_mic> node.name=loopback_capture" \ --playback-props "media.class=Audio/Source node.name=Virtual_Headset_Mic node.description=Virtual_Headset_Microphone"
-
Mute Behavior: Sends HID mute button pulse (0β1β0 transition)
- Apps detect the Relative toggle and handle muting internally
- No system-level muting (apps control their own audio processing)
Zoom's WebHID code matches devices by checking if the audio device label includes the HID device product name:
device = devices.find((d) => audioLabel.includes(d.productName));Since our audio device is "Virtual_Headset_Microphone" and HID device is "Virtual_Headset", the match succeeds.
Important
This is the crux of why we need a forwarded audio source created. Another approach could be to create an hid device of a chosen audio source, but this probably has some edge cases. We can guarantee functionality by making it ourselves.
Note
Fun fact: Google meet does not have this same requirement as Zoom. So the audio microphone is optional and you may use whatever microphone you like with the virtual hid device
The program requires access to:
/dev/uhid- Create virtual HID devices/dev/hidraw*- Browser WebHID access to the created device
The included NixOS module sets up udev rules automatically. See nixosModules/default.nix for details.
Add udev rules to /etc/udev/rules.d/99-virtual-headset.rules:
# Allow access to /dev/uhid for creating virtual HID devices
KERNEL=="uhid", MODE="0660", GROUP="input", TAG+="uaccess"
# Allow browser WebHID access to virtual headset device
# Matches Jabra vendor (0x0b0e) product (0x245e)
KERNEL=="hidraw*", KERNELS=="0003:0B0E:245E.*", MODE="0666", TAG+="uaccess"
Add your user to the input group:
sudo usermod -aG input $USERReload udev:
sudo udevadm control --reload-rules
sudo udevadm trigger-
Check the device was created:
ls -l /dev/hidraw* # Should show device owned by you or mode 0666
-
Check in browser console (F12):
navigator.hid.getDevices(); // Should show "Virtual_Headset" if previously authorized
-
Check audio device name matches:
pactl list sources | grep -A5 Virtual_Headset
-
Check HID events are being sent:
sudo evtest # Select the Virtual_Headset device # Press 'm' - should see KEY_MICMUTE events
-
Check Zoom connected to the device:
- Look for "Device opened by host" message in terminal
- Should see "Host LEDs" messages when you mute/unmute in Zoom
- Make sure you're in the
inputgroup:groups | grep input - Check udev rules are loaded:
udevadm info /dev/uhid - Restart after adding udev rules
# Check flake and build all packages
just check
# Build the default package
just build
# Run the virtual headset
just run
# Show flake outputs
just show# or, simply `direnv allow` for automatic activation
nix developThis provides:
- Rust toolchain (cargo, rustc, rust-analyzer, clippy, rustfmt)
- Required system libraries
- Code formatting tools (treefmt)
# or `nix fmt`, if you do not have the devshell activated
treefmt- Amazing details and base knowledge gained here
- Important background information about HID report descriptors
- UHID - User-space I/O driver support for HID subsystem
- Project uses
- uhid-virt for virtual HID device creation
MIT License