61-key 5-Octave Mallet MIDI Controller

by DrumStyx1986 in Living > Music

2288 Views, 27 Favorites, 0 Comments

61-key 5-Octave Mallet MIDI Controller

IMG_3198.jpg

This project is a 5-octave MIDI controller designed to mimic instruments like a marimba, vibraphone, xylophone, etc. My main driving force behind this project was that, as percussionists know, 5-octave, 61-key mallet instruments are incredibly expensive and take up a ton of space. Add in the fact that you may want a marimba, xylophone, glockenspiel, vibraphone, etc…you’ve now invested tens of thousands of dollars and need a whole area of your house just for the mallet instruments. So I wanted to make a “simple” MIDI controller that was designed to not only be used with mallets, but provide ample space on each bar so that 4-mallet players would not struggle with accuracy. Simply plug the Teensy into your computer and use it with your preferred MIDI software. The resulting device works well with mallets and can even detect dynamics! After I finished the project in 2025, I had only invested around $300.

To see it in action, check out the video!

https://vimeo.com/1115222146?fl=pl&fe=sh


61-Key Mallet-Driven Midi Controller © 2025 by Chris Creech is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/4.0/

Supplies

IMG_3203.jpg

Soldering iron

Drill

Saw

Small gauge stranded wire (22-24 gauge, approximately 330’)

122 machine screws, I used 10-24 x 1 3/4”

Longer machine screws for bars that are over supports in the middle

122 nuts to match machine screws

1/2” neoprene, approximately 2 square feet.

1/16” self-adhesive neoprene, approximately 4 square feet.

1/4” plywood or similar material, approximately 8.1 square feet. I used pegboard since I had scrap lying around.

1x4 lumber, approximately 48’ total.

Hinges, I used 3” strap hinges. These are optional, only if you want to be able to fold the instrument in half.

1 Teensy 4.1

4 CD74HC4067DB Multiplexers

Solder (I used 60/40 rosin-core)

61 20mm Piezo disc triggers

RTV sealant

61 1megohm resistors

61 3.3v Zener diodes

Wood screws (1”)

Cut and Assemble Frame and Cut Bars

IMG_3199.jpg

First, we'll assemble the frame.

1.) Cut four 1x4s to 100" each. This will allow space for all the bars plus room for the Teensy and a computer to sit on the end.

2.) For the supports, we need two 19" 1x4s and two 9.75" 1x4s. This will make a support for each end. Stack a 9.75" 1x4 on a 19" piece and screw together. Repeat that for the second support. You can see how these are aligned in the photos. If you choose to hinge the middle, you will need 2 more supports.

3.) Your final cuts will be the bars. We need 61 bars, and they need to measure 9.25" by 2" unless you want to edit the size. However, this will change the measurements on everything else.

4.) Taking care to space evenly (check spacing at both ends and in the middle), lay two of your 100" 1x4s parallel down the long end of the support and secure with screws. Repeat for the raised portion of the support. This will mimic the mallet instruments raised sharp and flat keys.

Attach Piezos to Bars

IMG_3204.jpg

As shown in the photos, attach the piezos as close to the middle of the bars as you can get. I used RTV sealant. Superglue was not resistant to vibration, and epoxy resin seemed very brittle. A little RTV sealant on about 1/3 to 1/2 of the back of the piezo allowed the piezo to flex and vibrate as needed.

Wire Multiplexers and Teensy

534984663_24065592999791057_7413638000185026718_n.jpg

Connect the multiplexers to the Teensy as shown in the wiring table in the photos. The columns put the multiplexers in order, for example: MUX 1 SIG goes to Teensy A0, MUX1 EN goes to Teensy Pin 2, MUX 2 SIG goes to Teensy A1, etc. Any shared connections should be bundled and secured with tape, wire nuts, etc.

Attach Bars to Frame

IMG_3200.jpg
IMG_3202.jpg

Each bar needs to be attached to the frame. To maintain proper spacing, I used 1/4" spacers (you can use scrap from your 1/4" plywood). As pictured, each bar will have 1/2" of neoprene between the bar and the frame. I stacked two 1/4" squares, each approximately 1.5 x 1.5 inches. Once your bar is in place, drill two holes big enough for your machine screws, one on each end of the bar. I went close to the edge of the bar so I could have more playable surface on each bar. Secure each bar with machine screws in each hole and tighten the nuts on the bottom. I also loosened each nut to have about 1/8" space so it wasn't tight. This allows some shock absorption and also for the keys to move slightly when played, mimicking a mallet instrument. I used superglue or Loctite to keep the nuts from vibrating loose. I then placed an adhesive-backed strip of 1/16" neoprene on each bar.

IMPORTANT: If you choose to hinge the instrument, choose a section to place the hinges between either a B and C or an E and F. This makes sure your hinge doesn't center on a sharp or a flat. When you know where you want it, do not add any bars after it until you cut the frame for the hinge. Add the hinge and supports, and then continue adding bars. If you don't, your spacing will be wrong because of the blade thickness.

Wire Piezos and Components and Wire to Multiplexers

IMG_3205.jpg

Now wire all 61 piezos to the resistors, diodes, and multiplexers. Each resistor and diode will be wired in parallel with the piezo as pictured. The banded end of the Zener diode needs to be connected to your signal end of the piezo. You will also need to connect a signal wire to run to the multiplexers, and a ground wire will run to a common ground that will terminate with the ground wire from the Teensy. Each key is in order. For example, the first key on the board (C) will be wired to Multiplexer 1, C0. C# will be Multiplexer 1, C1. D will be Multiplexer 1, C2, etc. Once these are soldered in place, manage the cables underneath. I stapled zip ties into the frame and then used the zip ties to bundle the cables.

Upload Code

Screen Shot 2025-09-02 at 6.25.18 PM.png

Once the Teensy 4.1 is connected to your computer and all the proper software (Arduino IDE, etc) is installed, open Arduino IDE and select the Teensy. Under "tools," select USB type as "Midi" and copy in this code. Upload the code and you're ready to customize!


// Benchmark sketch for 61 piezo inputs (4x CD74HC4067)

// Adds velocity modes:

// - VELOCITY_MODE 0: Global Linear

// - VELOCITY_MODE 1: Global Gamma (curved)

// - VELOCITY_MODE 2: Per-Channel Linear AGC <-- DEFAULT (best pad-to-pad consistency)

//

// Fast scan settings you’ve been using:

// - settleDelayUs = 10

// - samplesPerChan = 2

// - interSampleUs = 20

// Thresholds = 70 default, with custom overrides below

// Prints NOTE ON/OFF + Peak/Base/TrigThresh/Headroom + Vel


#include <Arduino.h>


const int NUM_MUX = 4;

struct MuxConfig { int sigPin; int enPin; int channels; };

MuxConfig muxConfig[NUM_MUX] = {

{A0, 2, 16}, // MUX1

{A1, 3, 16}, // MUX2

{A2, 4, 16}, // MUX3

{A3, 5, 13} // MUX4

};


// Shared select pins

const int S0 = 6;

const int S1 = 7;

const int S2 = 8;

const int S3 = 9;


// ---- Timing ----

const unsigned int settleDelayUs = 10;

const unsigned int interSampleUs = 20;

const int samplesPerChan = 2;


// ---- Trigger behavior ----

const int dynamicThreshold = 40;

const int debounceTime = 50; // ms

const int retriggerDelay = 70; // ms


// ---- Velocity behavior ----

const int maxVelocity = 127;

const int minVelocity = 20;


// Velocity modes:

// 0 = global linear

// 1 = global gamma (curved)

// 2 = per-channel linear AGC (recent max headroom) <-- Recommended default

#define VELOCITY_MODE 2


// Global linear/gamma params (used in modes 0/1)

float globalHeadroomMax = 400.0f; // adjust to taste; higher → softer overall

float curveGamma = 0.60f; // <1 expands soft hits, >1 compresses


// Per-channel AGC params (used in mode 2)

float headroomFloor = 100.0f; // minimum dynamic span per channel

float headroomCeil = 900.0f; // cap to avoid insane spikes

float recMaxDecay = 0.995f; // decay factor per scan of a channel


// ---- Global state ----

const int TOTAL_CHANNELS = 16 + 16 + 16 + 13; // 61

int midiNotes[TOTAL_CHANNELS];

bool noteIsOn[TOTAL_CHANNELS];

elapsedMillis timers[TOTAL_CHANNELS];


// Per-channel data

int baseThresholds[TOTAL_CHANNELS];

int baselines[TOTAL_CHANNELS];

int lastPeakSeen[TOTAL_CHANNELS];


// For mode 2 (per-channel AGC)

float recentMaxHeadroom[TOTAL_CHANNELS];


// Debug

elapsedMillis sweepPrintTimer;


// ---- helpers ----

void selectMuxChannel(int channel) {

digitalWrite(S0, (channel >> 0) & 1);

digitalWrite(S1, (channel >> 1) & 1);

digitalWrite(S2, (channel >> 2) & 1);

digitalWrite(S3, (channel >> 3) & 1);

}


int channelIndex(int mux, int ch) {

int idx = 0;

for (int m = 0; m < mux; m++) idx += muxConfig[m].channels;

return idx + ch;

}


int computeVelocity(int chanIdx, int headroom) {

if (headroom <= 0) return 0;


// clamp headroom sanity

float hr = (float)headroom;

if (hr < 0) hr = 0;


int velocity = minVelocity;


#if VELOCITY_MODE == 0

// ---- Global Linear ----

float norm = hr / globalHeadroomMax; // 0..~1

if (norm > 1.0f) norm = 1.0f;

velocity = minVelocity + (int)(norm * (maxVelocity - minVelocity));


#elif VELOCITY_MODE == 1

// ---- Global Gamma ----

float norm = hr / globalHeadroomMax;

if (norm > 1.0f) norm = 1.0f;

// gamma curve: norm^(1/gamma) if gamma < 1 expands soft region

float curved = powf(norm, 1.0f / curveGamma);

if (curved > 1.0f) curved = 1.0f;

velocity = minVelocity + (int)(curved * (maxVelocity - minVelocity));


#elif VELOCITY_MODE == 2

// ---- Per-Channel Linear AGC ----

// Decay the recent max slightly each pass a channel is scanned.

recentMaxHeadroom[chanIdx] *= recMaxDecay;

if (hr > recentMaxHeadroom[chanIdx]) {

recentMaxHeadroom[chanIdx] = hr;

}


// Effective max range for this channel

float effectiveMax = recentMaxHeadroom[chanIdx];

if (effectiveMax < headroomFloor) effectiveMax = headroomFloor;

if (effectiveMax > headroomCeil) effectiveMax = headroomCeil;


float norm = hr / effectiveMax;

if (norm > 1.0f) norm = 1.0f;

velocity = minVelocity + (int)(norm * (maxVelocity - minVelocity));

#endif


if (velocity > maxVelocity) velocity = maxVelocity;

if (velocity < minVelocity) velocity = minVelocity;

return velocity;

}


void setup() {

Serial.begin(115200);

usbMIDI.begin();


pinMode(S0, OUTPUT); pinMode(S1, OUTPUT);

pinMode(S2, OUTPUT); pinMode(S3, OUTPUT);


for (int m = 0; m < NUM_MUX; m++) {

pinMode(muxConfig[m].enPin, OUTPUT);

digitalWrite(muxConfig[m].enPin, HIGH);

}


int note = 36;

for (int i = 0; i < TOTAL_CHANNELS; i++) {

midiNotes[i] = note++;

noteIsOn[i] = false;

baselines[i] = 10;

lastPeakSeen[i] = 0;

baseThresholds[i] = 70; // default

recentMaxHeadroom[i]= 300.0f; // good starting span (tunes itself)

}


// ---- Apply your latest custom thresholds ----

baseThresholds[channelIndex(0, 2)] = 60; // MUX1C2

baseThresholds[channelIndex(0, 7)] = 110; // MUX1C7

baseThresholds[channelIndex(0, 14)] = 90; // MUX1C14


baseThresholds[channelIndex(1, 0)] = 130; // MUX2C0

baseThresholds[channelIndex(1, 3)] = 220; // MUX2C3

baseThresholds[channelIndex(1, 4)] = 200; // MUX2C4

baseThresholds[channelIndex(1, 6)] = 50; // MUX2C6

baseThresholds[channelIndex(1, 10)] = 140; // MUX2C10

baseThresholds[channelIndex(1, 14)] = 120; // MUX2C14


baseThresholds[channelIndex(2, 2)] = 90; // MUX3C2

baseThresholds[channelIndex(2, 7)] = 170; // MUX3C7

baseThresholds[channelIndex(2, 10)] = 150; // MUX3C10

baseThresholds[channelIndex(2, 11)] = 90; // MUX3C11

baseThresholds[channelIndex(2, 12)] = 120; // MUX3C12

baseThresholds[channelIndex(2, 14)] = 100; // MUX3C14


baseThresholds[channelIndex(3, 6)] = 120; // MUX4C6

baseThresholds[channelIndex(3, 8)] = 150; // MUX4C8

baseThresholds[channelIndex(3, 9)] = 200; // MUX4C9

baseThresholds[channelIndex(3, 11)] = 160; // MUX4C11


sweepPrintTimer = 0;


Serial.print("Benchmark loaded: samples=");

Serial.print(samplesPerChan);

Serial.print(", velocity mode=");

Serial.println(VELOCITY_MODE);

}


void loop() {

unsigned long sweepStart = micros();


int globalChannel = 0;

for (int m = 0; m < NUM_MUX; m++) {

int sigPin = muxConfig[m].sigPin;

int channels = muxConfig[m].channels;


for (int ch = 0; ch < channels; ch++) {

for (int mm = 0; mm < NUM_MUX; mm++) digitalWrite(muxConfig[mm].enPin, HIGH);


selectMuxChannel(ch);

delayMicroseconds(settleDelayUs);


digitalWrite(muxConfig[m].enPin, LOW);


int peak = 0;

for (int s = 0; s < samplesPerChan; s++) {

int val = analogRead(sigPin);

if (val > peak) peak = val;

delayMicroseconds(interSampleUs);

}


digitalWrite(muxConfig[m].enPin, HIGH);


lastPeakSeen[globalChannel] = peak;

// Slow baseline to avoid following transients

baselines[globalChannel] = (baselines[globalChannel] * 31 + peak) / 32;


int trigThresh = baselines[globalChannel] + baseThresholds[globalChannel] + dynamicThreshold;

int headroom = peak - trigThresh;


// ---- Velocity

int velocity = computeVelocity(globalChannel, headroom);


// NOTE ON

if (peak > trigThresh && !noteIsOn[globalChannel] && timers[globalChannel] > debounceTime) {

usbMIDI.sendNoteOn(midiNotes[globalChannel], velocity, 1);

noteIsOn[globalChannel] = true;

timers[globalChannel] = 0;


Serial.print("NOTE ON | MUX"); Serial.print(m+1);

Serial.print(" C"); Serial.print(ch);

Serial.print(" | Note "); Serial.print(midiNotes[globalChannel]);

Serial.print(" | Vel: "); Serial.print(velocity);

Serial.print(" | Peak: "); Serial.print(peak);

Serial.print(" | Base: "); Serial.print(baselines[globalChannel]);

Serial.print(" | TrigThresh: "); Serial.print(trigThresh);

Serial.print(" | Headroom: "); Serial.println(headroom);

}


// NOTE OFF

if (noteIsOn[globalChannel] && timers[globalChannel] > retriggerDelay && peak < (trigThresh / 2)) {

usbMIDI.sendNoteOff(midiNotes[globalChannel], 0, 1);

noteIsOn[globalChannel] = false;


Serial.print("NOTE OFF | MUX"); Serial.print(m+1);

Serial.print(" C"); Serial.print(ch);

Serial.print(" | Note "); Serial.print(midiNotes[globalChannel]);

Serial.print(" | Peak: "); Serial.print(peak);

Serial.print(" | TrigThresh: "); Serial.print(trigThresh);

Serial.print(" | Headroom: "); Serial.println(headroom);

}


globalChannel++;

}

}


usbMIDI.read();


unsigned long sweepTimeUs = micros() - sweepStart;

if (sweepPrintTimer > 5000) {

Serial.print("Full sweep time: ");

Serial.print(sweepTimeUs);

Serial.println(" us");

sweepPrintTimer = 0;

}

}


Test and Play!

Screen Shot 2025-09-02 at 6.26.15 PM.png

The code applies custom thresholds for a lot of my bars that had crosstalk. These will need to be added, adjusted, or removed for your specific application.