Game show feeling: How I created a hardware mute button for Linux

thormeier

Pascal Thormeier

Posted on November 19, 2020

Game show feeling: How I created a hardware mute button for Linux

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:

Big red button
(Image from Amazon.com)

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]])
      })
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
# ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

(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 🐦!

Buy me a coffee button

💖 💪 🙅 🚩
thormeier
Pascal Thormeier

Posted on November 19, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related