Sensing hit-velocity and quick subsequent hits of a piezo with an Arduino / Teensy

Using a piezo and an Arduino / Teensy to create an electronic drum pad is not an unusual task. I wanted my pad to be velocity-sensitive however, and a quick search of the Web didn’t provide any code for doing this. Some pages suggested using a capacitor in the circuit to “hold” the piezo’s signal value for a longer time. But the method I eventually developed does not require any additional non-standard piezo circuitry – except I had to add an additional resistor to dampen the piezo’s signal, whose voltage got too high for my Teensy when hit hard. If your piezo’s signal is reaching your Arduino / Teensy’s maximum analogRead() level (usually 1023), you’ll need to do this too.

The stock-standard way to get Arduino code to register “hit” information from a piezo is to get it to listen to the analog input from it, and register a hit whenever the piezo’s signal rises above a certain threshold.

Determining velocity from the piezo’s signal, however, is a different story. At the point in time at which the piezo’s signal first rises above a given threshold, there’s no way for the code to predict whether the signal will continue to rise quite high, meaning a high-velocity hit, or whether it’s already almost arrived at its peak, which would mean a low-velocity hit.

I tried a few different methods, and the most accurate by far was: when the signal breaches the threshold, have the code spend the next couple of milliseconds or so looking out for the highest reading of the piezo’s signal. At some point during these first couple of milliseconds, the piezo’s signal will reach its peak, and the value of this peak is an accurate indicator of the hit’s velocity.

A note here – piezos have a polarity. Which way around you wire it will change what the signal looks like – and from my experience, it’s not necessarily clear-cut which way is better. I suggest you try both. On the pad / piezo that I worked with, when wired one way the signal rose to its peak in only about 0.5ms (good) but this peak was a poor indicator of velocity; the other way around the peak usually took around 2ms to reach (not as good but still fine) but was a much better indicator of velocity, but the piezo received much more “cross-talk” from hits to other pads on the same object.

Great, so now we have a velocity-sensitive drum pad. One problem though is that once our code has registered the hit, we have to tell it to stop “listening” to the piezo for a little while – say, 200ms – because the piezo’s signal will remain above the hit-threshold for a portion of this time, and we don’t want this to cause the incorrectly registering of any additional hits. This unfortunately means, however, that the user can’t perform hits too close together, or else the code will miss subsequent ones entirely. How can we get around this?

After a given hit, the piezo’s signal will fall gradually, and we can put this fact to use in our method of listening for a quick subsequent hit. We know that immediately after the initial hit, the piezo’s signal is likely to still be quite high – so we’d only want to register a subsequent hit if we saw the signal go really high. A few milliseconds later, though, we’d expect the piezo’s signal to have tapered off a bit after the initial hit, so our threshold for registering a subsequent hit would be lower.

Here is the code I came up with – functional for one single drum pad, and turning its signal into USB MIDI note-ons thanks to Teensy. The line which references usbMIDI is the only bit that won’t work on a non-Teensy Arduino. Fine-tune the code by altering the constants at the top – I’m sure these are heavily dependent on the the piezo and pad-object you’re using.

#define triggerThreshold 10 // If this is set too low, hits on other pads will trigger a "hit" on this pad
#define initialHitReadDuration 500 // In microseconds. Shorter times will mean less latency, but less accuracy. 500 microseconds is nothing, anyway
#define midiVelocityScaleDownAmount 2 // Number of halvings that will be applied to MIDI velocity
#define inputPin A0

// Getting the ideal balance of these two constants will ensure that fast subsequent hits are perceived accurately, but false hits are not generated
#define subsequentHitThreshold 1.7
#define subsequentHitThresholdDecaySpeed 14

uint16_t highestYet;
uint32_t startReadingTime;
uint32_t highestValueTime;
boolean hitOccurredRecently = false;

void setup() {
}

void loop() {

  // Assume the normal hit-threshold
  uint16_t thresholdNow = triggerThreshold;

  // But, if a hit occurred very recently, we need to set a higher threshold for triggering another hit, otherwise the dissipating vibrations
  // of the previous hit would trigger another one now
  if (hitOccurredRecently) {

      // Work out how high a reading we'd need to see right now in order to conclude that another hit has occurred
      uint16_t currentDynamicThreshold = (highestYet >> ((micros() - highestValueTime) >> subsequentHitThresholdDecaySpeed)) * subsequentHitThreshold;

      // If that calculated threshold is now as low as the regular threshold, we can go back to just waiting for a regular, isolated hit
      if (currentDynamicThreshold <= triggerThreshold) hitOccurredRecently = false;

      // Otherwise, do use this higher threshold
      else thresholdNow = currentDynamicThreshold;
  }

  // Read the piezo
  uint16_t value = analogRead(inputPin);

  // If we've breached the threshold, it means we've got a hit!
  if (value >= thresholdNow) {
    startReadingTime = micros();
    highestYet = 0;

    // For the next few milliseconds, look out for the highest "spike" in the reading from the piezo. Its height is representative of the hit's velocity
    do {
      if (value > highestYet) {
        highestYet = value;
        highestValueTime = micros();
      }
      value = analogRead(inputPin);
    } while (timeGreaterOrEqual(startReadingTime + initialHitReadDuration, micros()));

    // Send the MIDI note
    usbMIDI.sendNoteOn(0, (highestYet >> midiVelocityScaleDownAmount) + 1, 1); // We add 1 onto the velocity so that the result is never 0, which would mean the same as a note-off
    Serial.println(highestYet); // Send the unscaled velocity value to the serial monitor too, for debugging / fine-tuning
    hitOccurredRecently = true;
  }
}

// Compares times without being prone to problems when the micros() counter overflows, every ~70 mins
boolean timeGreaterOrEqual(uint32_t lhs, uint32_t rhs) {
  return (((lhs - rhs) & 2147483648) == 0);
}

Having tuned the above code for use in my own construction, I can say that it works very nicely, registering quick subsequent hits surprisingly accurately and giving consistent velocity values. It’s even fairly good at correctly registering the quick successive hits achieved by momentarily “dribbling” a drum stick with one hand. But it’s not perfect, and after a while spent fine-tuning the code’s approximation of the expected decay-shape of the piezo’s signal after an initial hit (used to determine what signal-change should count as another successive hit, as discussed above), I decided to bite the bullet and write an additional bit of code to actually record the real decay-shape of a given piezo attached to a given drum pad – so the code can really, really know if a change in input signal at a given number of milliseconds after an initial hit can count as another one.

I haven’t spent time commenting or fully documenting the resulting code, but in case it’s any use to you, here it is. The code listens to as many test-hits as you care to play it, and keeps a running high-score of the signal-level (relative to the overall velocity of the hit) at each number of milliseconds after the hit began. This is stored in your Teensy or Arduino’s EEPROM. Set “learning” to 1 to put it in this learning mode, and then back to 0 to have the code function normally. While in learning mode, make sure your hits are at least ~300ms apart – even one hit too soon would completely corrupt the learned data and you’d have to start again. Try to hit your pad in a variety of positions, at a variety of velocities.

#include <EEPROM.h>

#define triggerThreshold 3 // If this is set too low, hits on other pads will trigger a "hit" on this pad
#define initialHitReadDuration 500 // In microseconds. Shorter times will mean less latency, but less velocity-accuracy
#define midiVelocityScaleDownAmount 2 // Number of halvings that will be applied to MIDI velocity
#define inputPin A0

#define learning 0

uint16_t highestYet;
uint32_t startReadingTime;
uint32_t highestValueTime;
boolean hitOccurredRecently = false;
boolean newRecordSet;

void setup() {
  if (learning) {
    for (int i = 0; i < 256; i++) {
      EEPROM.write(i, 0);
    }
  }
}

void loop() {

  // Assume the normal hit-threshold
  uint16_t thresholdNow = triggerThreshold;

  // Read the piezo
  uint16_t value = analogRead(inputPin);

  uint32_t msPassed;

  // But, if a hit occurred very recently, we need to set a higher threshold for triggering another hit, otherwise the dissipating vibrations
  // of the previous hit would trigger another one now
  if (hitOccurredRecently) {

      uint32_t usPassed = micros() - highestValueTime;
      msPassed = usPassed >> 10;

      if (learning) {
        if (msPassed >= 256) {
          hitOccurredRecently = false;
          if (newRecordSet) {
            Serial.println("----------------------------------------");
            for (int i = 0; i < 256; i++) {
              Serial.print(String((int)EEPROM.read(i)) + ", ");
              if (i % 16 == 0) Serial.println();
            }
          }
        }
        else {
          uint16_t nowComparedToHighest = ceil((float)value / (float)highestYet * 256);
          uint8_t previousRecord = EEPROM.read(msPassed);
          if (nowComparedToHighest > previousRecord) {
            newRecordSet = true;
            EEPROM.write(msPassed, min(255, nowComparedToHighest));
          }
        }
        thresholdNow = 1024;
      }

      else {
        if (msPassed >= 256) hitOccurredRecently = false;
        else {
          // Work out how high a reading we'd need to see right now in order to conclude that another hit has occurred
          uint32_t currentDynamicThreshold;
          if (usPassed < initialHitReadDuration) currentDynamicThreshold = highestYet;
          else currentDynamicThreshold = ((uint32_t)EEPROM.read(msPassed) * highestYet) >> 8;

          thresholdNow += currentDynamicThreshold;
        }
      }
  }

  // If we've breached the threshold, it means we've got a hit!
  if (value >= thresholdNow) {

    startReadingTime = micros();
    highestYet = 0;

    // For the next few milliseconds, look out for the highest "spike" in the reading from the piezo. Its height is representative of the hit's velocity
    do {
      if (value > highestYet) {
        highestYet = value;
        highestValueTime = micros();
      }
      value = analogRead(inputPin);
    } while (timeGreaterOrEqual(startReadingTime + initialHitReadDuration, micros()));

    // Send the MIDI note
    usbMIDI.sendNoteOn(0, (highestYet >> midiVelocityScaleDownAmount) + 1, 1); // We add 1 onto the velocity so that the result is never 0, which would mean the same as a note-off
    Serial.println(highestYet); // Send the unscaled velocity value to the serial monitor too, for debugging / fine-tuning
    hitOccurredRecently = true;
    newRecordSet = false;
  }
}

// Compares times without being prone to problems when the micros() counter overflows, every ~70 mins
boolean timeGreaterOrEqual(uint32_t lhs, uint32_t rhs) {
  return (((lhs - rhs) & 2147483648) == 0);
}

Update 2015-02-15: I have since developed a version of this sketch to work with multiple drum pads simultaneously, plus some other improvements. Apologies – this is even more un-commented than the above code, but if you do want to take a look, download it here.