Fixing the USB microphone mute button click

In my previous article I documented the design and build process of my USB microphone based around an STM32F446 MCU. If you haven’t already read it then it’s probably worth catching up now before reading the rest of this article so that you have the necessary context.

The problem

I’ve been using the microphone for a while now and never really noticed that there was a noise issue with the hardware mute button until I recorded a sound file using Audacity that featured me coming in and out of mute. The noise issue is caused by my poor choice of hardware button:

These buttons are cheap PCB mounted momentary press-release buttons that have an audible click both on the press and the subsequent release. Unfortunately, because the button is located close to the INMP441 sensor the click is very audible.

Click here to listen to the problem. I come out of mute at the start and go back in at the end.

Given that I’m using this microphone daily for virtual meetings I can only assume that either the Skype audio is so bad that you can’t differentiate these clicks from the usual audio corruption that Skype randomly applies or my colleagues are too polite to tell me that I’m clicking at them.

The fix

I didn’t want to desolder the button from the board and bodge in a momentary button that doesn’t click because that would ruin the nice neat appearance of the board. Instead I decided to see what I could do in software.

General approach

Instead of always reacting to a button down event I would need to get smarter and react on the upward or downward button transition depending on whether I was going into, or coming out of mute. This needed a redesign of my generic Button class to inform me when either a new up or down state was reached, with a different reaction delay for up and down. Here’s the modified class:

/*
 * This file is part of the firmware for the Andy's Workshop USB Microphone.
 * Copyright 2021 Andy Brown. See https://andybrown.me.uk for project details.
 * This project is open source subject to the license published on https://andybrown.me.uk.
 */

#pragma once

/**
 * A debounced button class
 */

class Button: public GpioPin {

  public:
    enum CurrentState {
      None,
      Up,
      Down
    };

  private:

    static const uint32_t DEBOUNCE_UP_DELAY_MILLIS = 100;
    static const uint32_t DEBOUNCE_DOWN_DELAY_MILLIS = 1;

    enum InternalState {
      Idle,                         // nothing happening
      DebounceUpDelay,              // delaying...
      DebounceDownDelay
    };

    bool _pressedIsHigh;            // The button is electrically HIGH when pressed?
    InternalState _internalState;   // Internal state of the class

    bool _lastButtonReading;        // the last state we sampled
    uint32_t _transitionTime;       // the time of the last transition

  public:
    Button(const GpioPin &pin, bool pressedIsHigh);
    CurrentState getAndClearCurrentState();   // retrieve current state and reset to idle
};

inline Button::Button(const GpioPin &pin, bool pressedIsHigh) :
    GpioPin(pin) {

  _transitionTime = 0;
  _lastButtonReading = false;
  _pressedIsHigh = pressedIsHigh;
  _internalState = Idle;
}

/**
 * Get and reset the current state. This should be called in the main application loop.
 * @return The current state. If the current state is one of the up/down pressed states
 * then that state is returned and then internally reset to none so the application only
 * gets one 'notification' that the button is pressed/released.
 */

inline Button::CurrentState Button::getAndClearCurrentState() {

  // read the pin and flip it if this switch reads high when open

  bool buttonReading = getState();
  if (!_pressedIsHigh) {
    buttonReading ^= true;
  }

  const uint32_t now = HAL_GetTick();

  if (_lastButtonReading == buttonReading) {

    // no change in the button reading, we could be exiting the debounce delay

    switch (_internalState) {

    case DebounceUpDelay:
      if (now - _transitionTime > DEBOUNCE_UP_DELAY_MILLIS) {
        _internalState = Idle;
        return Up;
      }
      break;

    case DebounceDownDelay:
      if (now - _transitionTime > DEBOUNCE_DOWN_DELAY_MILLIS) {
        _internalState = Idle;
        return Down;
      }
      break;

    case Idle:
      break;
    }

    return None;

  } else {

    // button reading has changed, this always causes the state to enter the debounce delay

    _transitionTime = now;
    _lastButtonReading = buttonReading;
    _internalState = buttonReading ? DebounceDownDelay : DebounceUpDelay;

    return None;
  }
}

The user of this class calls getAndClearCurrentState() in the main loop and it will return Up or Down exactly once when there’s a transition and None otherwise.

Going into mute

When going into mute I want muting to happen immediately when the button is pressed down, hopefully before it emits a click. When the button comes back up it won’t matter because we’ll be in the muted state. To get that immediate response I set the debounce delay for a button down transition to 1ms in the Button class.

Coming out of mute

I want the transition out of mute to happen when the button comes up, and I want it to be delayed sufficiently that the click caused by releasing the button is skipped. To achieve that I set the debounce delay for a button up transition to 100ms in the Button class.

The above logic is encapsulated in the MuteButton subclass that distills everything down into a single isMuted method to return the current state.

/*
 * This file is part of the firmware for the Andy's Workshop USB Microphone.
 * Copyright 2021 Andy Brown. See https://andybrown.me.uk for project details.
 * This project is open source subject to the license published on https://andybrown.me.uk.
 */

#pragma once

class MuteButton: public Button {

  private:
    volatile bool _muted;
    bool _ignoreNextUp;

  public:
    MuteButton();

    void run();
    bool isMuted() const;
};

inline MuteButton::MuteButton() :
    Button(GpioPin(MUTE_GPIO_Port, MUTE_Pin), false) {

  _muted = false;
  _ignoreNextUp = false;
}

inline void MuteButton::run() {

  Button::CurrentState state = getAndClearCurrentState();

  // check for idle

  if (state == Button::CurrentState::None) {
    return;
  }

  if (state == Down) {

    if (!_muted) {
      _muted = true;
      _ignoreNextUp = true;   // the lifting of the button shouldn't exit mute
    }
  }
  else {

    if (_muted) {

      if (_ignoreNextUp) {

        // this is the lifting of the button that went into mute

        _ignoreNextUp = false;
      }
      else {
        _muted = false;
      }
    }
  }
}

inline bool MuteButton::isMuted() const {
  return _muted;
}

Testing and more fixes

With the above fixes I ran some tests by recording with Audacity and repeatedly pressing the mute button. For the most part it worked as I hoped but sometimes, about 1 in 5, there was still a 'pop' spike at either transition but now it sounded more like a pop due to a problem with the audio encoding rather than the 'click' of the button.

When muted my code zeros out the audio buffer before sending to the host and so, based on the hunch that this could cause a 'pop' sound at the transition into and/or out of mute I decided to change to enabling the soft 'mute' function of ST's Smart Volume Control (SVC) library that I was already using to control volume. The documentation in UM1642 has this to say about the mute function:

The SVC "mute" dynamic parameter mutes the output when set to 1 or has no influence on input signal when set to 0. When enabled, it allows mute the signal smoothly over a frame, avoiding audible artifacts.

This approach appeared to totally solve the problem when coming out of mute, the pop had totally gone no matter how many times I pressed and released the button. However, going into mute (where a fast reaction is required) it only improved slightly on the previous results. I suspect that the algorithm that smooths out the transitions is too slow to catch the pop sound.

To fix this last issue I went back to the method of zeroing out data frames. I found by experimentation that by zeroing out the first 500ms of data when transitioning into mute it solved the problem for at least 9/10 cases and I'm happy with that. The core sendData audio interrupt handler now looks like this.

inline void Audio::sendData(volatile int32_t *data_in, int16_t *data_out) {

  // only do anything at all if we're connected

  if (_running) {

    // ensure that the mute state in the smart volume control library matches the mute
    // state of the hardware button. we do this here to ensure that we only call SVC
    // methods from inside an IRQ context.

    if (_muteButton.isMuted()) {
      if (!_volumeControl.isMuted()) {
        _volumeControl.setMute(true);

        // the next 50 frames (500ms) will be zero'd - this seems to do a better job of catching the
        // mute button 'pop' than the SVC filter mute when going into a mute

        _zeroCounter = 50;
      }
    }
    else {
      if (_volumeControl.isMuted()) {

        // coming out of a mute is handled well by the SVC filter

        _volumeControl.setMute(false);
      }
    }

    if (_zeroCounter) {
      memset(data_out, 0, (MIC_SAMPLES_PER_PACKET * sizeof(uint16_t)) / 2);
      _zeroCounter--;
    }
    else {

      // transform the I2S samples from the 64 bit L/R (32 bits per side) of which we
      // only have data in the L side. Take the most significant 16 bits, being careful
      // to respect the sign bit.

      int16_t *dest = _processBuffer;

      for (uint16_t i = 0; i < MIC_SAMPLES_PER_PACKET / 2; i++) {

        // dither the LSB with a random bit

        int16_t sample = (data_in[0] & 0xfffffffe) | (rand() & 1);

        *dest++ = sample;     // left channel has data
        *dest++ = sample;     // right channel is duplicated from the left
        data_in += 2;
      }

      // apply the graphic equaliser filters using the ST GREQ library then
      // adjust the gain (volume) using the ST SVC library

      _graphicEqualiser.process(_processBuffer, MIC_SAMPLES_PER_PACKET / 2);
      _volumeControl.process(_processBuffer, MIC_SAMPLES_PER_PACKET / 2);

      // we only want the left channel from the processed buffer

      int16_t *src = _processBuffer;
      dest = data_out;

      for (uint16_t i = 0; i < MIC_SAMPLES_PER_PACKET / 2; i++) {
        *dest++ = *src;
        src += 2;
      }
    }

    // send the adjusted data to the host

    if (USBD_AUDIO_Data_Transfer(&hUsbDeviceFS, data_out, MIC_SAMPLES_PER_PACKET / 2) != USBD_OK) {
      Error_Handler();
    }
  }
}

Click here to listen to the audio after these fixes have been applied. I come out of mute at the start and go back in at the end.

Conclusion

Well obviously I should have thought ahead that a microphone project shouldn't really have noisy components situated right next to the sensor but at least I've been able to almost totally fix the problem with software alone so I'll have confidence using the mute feature in virtual meetings now.

The code fix has just been merged to master in Github so if you pull the latest changes you'll get the fixes.