I'm working from home since mid March due to the pandemic. (I'm priviledged enough to have an employer who allows this and made the switch as lean as possible for everyone.) I struggled in the beginning, though, all of a sudden all meetings I had were video calls. In the beginning, my camera didn't even work on Linux (I never had to use it before, so I didn't care), which is why I used my phone to do video calls for the first few days. I improved my setup at home ever since and I'm now at a point where I'm introducing more and more gimmicks and gadgets to it to make my life ever so slightly more convenient.
In this post I'll explain the latest addition to my setup: A hardware mute button for Linux!
Why, though?
Several reasons! First of all, because it's fun. The act of hitting a button before speaking gives me this game show feeling. Building and testing it was also fun, I love to tinker and make things. Furthermore: convenience. Not having to look for, aim and press a mute button on screen, but simply pressing a hardware button feels more convenient to me.
Some prerequisits
I installed the following things in order for this to work:
- pulseaudio (to control the mic)
- bash (executing pulseaudio commands)
- node (writing the device driver)
- systemd (enabling it as a service, upstart or similar might also do the trick)
If you're a web dev running Linux, chances are you already have these things installed anyways.
Getting the hardware
For a hardware mute button, I need hardware. Some years ago I ordered a few "big red buttons" by Dream Cheeky:
(I'm a bit of a tech hoarder...) But apparently the company doesn't exist anymore, which makes ordering them a bit hard. One can find used ones, though. And since it's USB, basically any button will do. Just make sure that it is pressable and has a USB connector. Search the internet for "big red button USB" and you'll find a myriad of options.
With the hardware ready, I went on to...
Toggling the mic on the CLI
I wasn't very seasoned with pulseaudio. A very Linux-savy friend of mine pointed me to a post on AskUbuntu from where I copied this command and put it in a file called mictoggle.sh
:
#!/bin/bash
pacmd list-sources | \
grep -oP 'index: \d+' | \
awk '{ print $2 }' | \
xargs -I{} pactl set-source-mute {} toggle
This toggles the mic's mute/unmute state when executed by listing all audio sources, extracting their index and calling pactl
with the command set-source-mute
on them. Now I needed to hook that up to the USB button.
Writing the device driver
Since everything that can be written in JavaScript eventually will be written in JavaScript, why not write a device driver for that button using Node?
I found a library that more or less did what I wanted, but had a few drawbacks since it used a state machine in the back (only one press was recognized, then I had to close and open the button's cover for it to recognize the next press), crashed when the button was disconnected and didn't recognize the button when newly connected while the script was running. So I drew some inspiration and the USB interface handling from this.
I first installed a package called usb:
npm i usb
Now I needed to figure out the button's VendorID and ProductID in order to connect to the right interface. Usually, with enough digging through existing libs and tutorials you can find those for your product, but a USB dump when connected can also yield the necessary info. For the Dream Cheeky button, those are 0x1d34
(vendor) and 0x000d
(product).
First, I wrote a function to fetch the button with these two IDs:
const usb = require('usb')
const getButton = (idVendor, idProduct) => {
return usb.findByIds(idVendor, idProduct)
}
Next, I get the button's interface, detach it from the kernel driver if necessary and claim it for this process. This I do in a function called getInterface
:
const getInterface = button => {
button.open()
const buttonInterface = button.interface(0)
if (button.interfaces.length !== 1 || buttonInterface.endpoints.length !== 1) {
// Maybe try to figure out which interface we care about?
throw new Error('Expected a single USB interface, but found: ' + buttonInterface.endpoints.length)
}
if (buttonInterface.isKernelDriverActive()) {
buttonInterface.detachKernelDriver()
}
buttonInterface.claim()
return buttonInterface
}
In order to fetch the state correctly, I needed some magic numbers:
const bmRequestType = 0x21
const bRequest = 0x9
const wValue = 0x0200
const wIndex = 0x0
const transferBytes = 8
Those magic numbers are parameters for the underlying libusb_control_transfer call which is one of two kinds of data exchanges USB can do (the other being a a functional data exchange). Convenient enough, the library I mentioned earlier had those already figured out via a USB dump.
I was now able to use those functions to listen to what was happening on the button:
const poll = button => {
const buttonInterface = getInterface(button)
const stateDict = {
21: 'close',
22: 'press',
23: 'open',
}
const endpointAddress = buttonInterface.endpoints[0].address
const endpoint = buttonInterface.endpoint(endpointAddress)
endpoint.timeout = 300
return new Promise((resolve, reject) => {
const buffer = new Buffer([0, 0, 0, 0, 0, 0, 0, 2])
button.controlTransfer(bmRequestType, bRequest, wValue, wIndex, buffer, (error, data) => {
if (error) {
reject(error)
}
endpoint.transfer(transferBytes, (error, data) => {
if (error) {
reject(error)
}
resolve(stateDict[data[0]])
})
})
})
}
I used this code to test if it was working at all:
setInterval(() => {
const button = getButton(idVendor, idProduct)
if (!button) {
return
}
poll(button).then(state => {
console.log(state)
}).catch(() => {})
}, 15)
So, every 15ms, the button is asked for its state which is then printed on stdout, like this (shortened version):
node ./bigRedButton.js
close
close
close
open
open
open
press
press
press
press
open
open
open
# ...
And there's a problem: The "press" state is active as long as the button is pressed. Now I understood why the library was using a state machine: The callback should only be executed once the button is pressed, not as long as the button is pressed. This I could work around. I also packed the code into a function that takes a few callbacks:
const listenToButton = (openCallback, pressCallback, closeCallback) => {
var isPressed = false
setInterval(() => {
const button = getButton(idVendor, idProduct)
if (!button) {
return
}
poll(button).then(state => {
if (isPressed && state !== 'press') {
// Not pressing anymore
isPressed = false
}
if (!isPressed && state === 'press') {
isPressed = true
// Executes the callback at the beginning of a button press
pressCallback()
}
if (state === 'open') {
openCallback()
}
if (state === 'close') {
closeCallback()
}
}).catch(() => {})
}, 15)
}
module.exports = listenToButton
Now I had an importable lib to use together with the mic toggle script. Since it tries to claim the button every time and just swallows any errors, disconnecting and reconnecting the button works like a charm.
Now I only needed to glue the pieces together:
const bigRedButton = require('./bigRedButton')
const { exec } = require('child_process')
const openCallback = () => {}
const pushCallback = () => {
exec('XDG_RUNTIME_DIR=/run/user/1000 ./mictoggle.sh')
}
const closeCallback = () => {}
bigRedButton(openCallback, pushCallback, closeCallback)
(The XDG_RUNTIME_DIR
env variable is necessary to execute pulseaudio commands in a non-interactive shell. During testing, it wasn't working until I figured this out.)
Executing this script now turned the big red button into a hardware mute button!
Make it a service
To make the mute button work on startup, I created a service file under /lib/systemd/system
with this content:
[Unit]
Description=Hardware mute button
After=multi-user.target
[Service]
Type=simple
User=USER
ExecStart=/home/USER/.nvm/versions/node/v14.15.0/bin/node /home/USER/projects/mutebutton/index.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
(Simply adjust the ExecStart
paths and replace USER
with your users name.)
Then I started the service (sudo systemctl start mutebutton
), tried the button a few times, giggled with joy, enabled the service on startup (sudo systemctl enable mutebutton
), rebooted, tried the button again, giggled again, and was happy with my result.
Takeaway thoughts
I didn't know much about USB and libusb before this little side project, but I learned a lot in the process. This thing has once again proved that "searching the internet" and "just trying things until it works" make for some great teachers.
Video calls became a lot more fun since I installed this button and I'm now actually looking forward to more video calls and hitting the button. Just like in game shows!
I hope you enjoyed reading this article! If so, leave a ❤️ or a 🦄! I write tech articles in my free time and like to drink coffee every once in a while.
If you want to support my efforts, please consider buying me a coffee ☕ or follow me on Twitter 🐦!
Top comments (5)
With systemd, you can create a user service, so that you don't need root permissions to create the file and whatnot.
You create the
.service
file the same way, but withoutUser=USER
line, and change theWantedBy
todefault.target
Save this file to ~/.config/systemd/user/
Now, for all your
systemd
commands, instead ofsudo systemd
, dosystemd --user
.So:
After reboot, it'll start up when you log in.
And when you want to read the logs, just do
journalctl --user -u mutebutton
.Here's some more info: wiki.archlinux.org/index.php/syste...
Thank you for this hint, I wasn't aware of this being possible! Is there any drawbacks to this?
I've been using it a lot for myself lately. I've only found two downsides.
--user
systemd
doesn't start until the user's first login. So, if I need something to start right away, even if the user hasn't logged in yet, I need to use the system-widesystemd
instead. This has never been a real issue to me, though, since I log in to my user as soon as I boot up the computer.I've actually been using
systemd
to replace my user's crontab recently as well.Oh wow, didn't know this was possible, either! I really need to dig deeper into
systemd
then, thank you!