Greetings everyone, and welcome back!
A few weeks ago, I built my own version of the Pip-Boy, complete with a custom-designed 3D-printed enclosure and a UI that I developed entirely from scratch. It was a pretty fun build, and while the hardware and 3D design are pretty much finalized, the UI is a different story.
Since it's entirely software-based, I can keep adding new features and turning it into practically anything I want!
Recently, I came across a drum kit app on the Apple App Store and thought, wouldn't it be cool if I could build my own version and integrate it directly into the Pip-Boy UI?
Well, that's exactly what I did! In this article, I'll walk you through how I brought this idea to life and turned my Pip-Boy into a functional digital drum kit.
Let's get started!
MATERIALS REQUIREDThese were the materials required for this project.
- Waveshare ESP32 P4 4.3inch Dev Board
- Power Source- 3.7V 2000mAh Li-ion cell
- 3D Printed Parts
- Slide Switch
- M2 screws
- M3 Threaded Inserts
- M3 Bolts
- M6 Nut
- M6 Thumb bolt
- Automotive filler
- Sandpapers
- Spray paint
- Spray primer
- Hyper PLA x 2 Rolls for all parts
- Patience and Time
A while back, I built my own version of the Pip-Boy from the Fallout game series, complete with a custom 3D-printed enclosure and a UI that I designed entirely from scratch. It was a pretty fun project, combining my love for gaming, electronics, and 3D design into one functional device.
The Pip-Boy features several interactive menus, including a Status screen, a Perks menu, an NTP-powered clock, an SD card-based music player with animated waveforms, and even a map of my hometown redesigned in the style of Fallout: New Vegas. I also added classic Pip-Boy sound effects and multiple UI themes to make the experience feel as close to the original game as possible.
The entire build process was quite tedious. However, one of the best things about building the interface from scratch is that it's completely customizable. While the hardware and 3D design are pretty much finalized, the software opens up endless possibilities for adding new features and experimenting with different ideas.
Check out the full project article linked below.
https://www.hackster.io/Arnov_Sharma_makes/pipboy-ar-mark-6000-46692c
HARDWARE - WAVESHARE ESP32 P4 4.3-inch DEV BOARDWe are reusing the hardware from the previous Pipboy; I used the Waveshare ESP32-P4-WIFI6-Touch-LCD-4.3 development board.
There were a couple of reasons why I went with this particular board. The first was the built-in 4.3-inch 480 Γ 800 capacitive touchscreen, which was almost perfect for the Pip-Boy interface. Having the display and touch hardware already integrated also made the overall design much simpler.
The second, and probably more important reason, was the ESP32-P4 itself. Itβs one of Espressifβs high-performance ESP32 chips, featuring a dual-core RISC-V processor running at up to 400 MHz, along with AI and DSP instruction extensions, a floating-point unit, dedicated multimedia hardware, and a much more capable memory and peripheral system than the ESP32 boards Iβve traditionally worked with.
For this project, that extra processing power was particularly useful because I wanted the Pip-Boy to handle a fairly graphical user interface, animations, audio, touch interaction, and media playback. The board also includes an ESP32-C6 wireless co-processor, giving the setup Wi-Fi 6 and Bluetooth 5 capabilities.
https://docs.waveshare.com/ESP32-P4-WIFI6-Touch-LCD-4.3
Another thing that made working with this board easier was the documentation. Waveshare has a dedicated Wiki/documentation page for the board, with hardware information, examples, and development resources. I relied on this quite a bit during development, especially while figuring out the display, touch interface, and other peripherals.
Overall, the combination of a large touchscreen, powerful ESP32-P4 processor, and built-in peripherals made this board a really good fit for building my custom Pip-Boy.
WAVESHARE SERVICESpecial thanks to Waveshare for providing the hardware used in this project. The ESP32 P4 4.3inch Dev Board and supporting accessories were supplied as review units for testing and evaluation.
Waveshare is a leading global provider of electronic components, modules, and development tools used across robotics, IoT, automation, education, and many other fields. With a strong focus on quality, reliability, and continuous innovation, Waveshare has earned the trust of engineers, designers, hobbyists, and makers worldwide.
Their extensive product lineup, from displays and HATs to expansion boards and embedded modules, makes them a go-to choice for both professional builds and DIY projects.
ELECTRONIC DRUMKITBefore diving deep into the code and build of Drumkit, lets have some basics, A traditional drum kit consists of several instruments, each producing a unique sound that contributes to the overall rhythm. The snare drum provides sharp, crisp beats, while the bass drum delivers the deep kick that drives the rhythm. The high, mid, and floor toms add different pitches for fills and transitions.
Cymbals bring their own character to the mix. The hi-hat produces tight, rhythmic sounds in both open and closed modes, while the crash cymbals deliver sharp accents and the ride cymbal provides a steady rhythm. We also included auxiliary triggers for additional sounds, such as claps.
In our virtual DrumKit, each of these instruments has its own dedicated touch area, allowing you to play different sounds simply by tapping the corresponding element on the screen.
DESIGNING FROM THE IMAGEWhen studying the reference image, two things immediately stood out.
Real drums don't have text labels.
Drummers recognize each instrument by its size, shape, and position rather than by labels like CRASH or SNARE printed across the drumheads. We wanted to recreate that same experience in our interface without cluttering the screen with text.
Physical drums can overlap, but touchscreen controls shouldn't.
On a real drum kit, cymbals often hang over toms, and the drums sit close together. However, on a capacitive touchscreen using LVGL, overlapping interactive widgets can cause touch events to be intercepted by another element, making accurate drumming difficult.
To solve these challenges, we removed all text labels from the drumheads and recreated each instrument using vector shapes. Each interactive element has its own defined touch area, allowing us to maintain the visual arrangement of a real drum kit while keeping the controls responsive.
CODEHere's the code we used to build the drum kit interface pipboy_dkit_tab.c / drum_ui.c. While it looks long, the logic is quite straightforward.
/*
* pipboy_dkit_tab.c β Pip-Boy 3000 Mk.IV DKit (Drum Kit) Tab
* Landscape 800x388 inside Pip-Boy Tabview (Pure visual drumheads, no text clutter)
*/
#include "pipboy_dkit_tab.h"
#include "pipboy_theme.h"
#include "pipboy_style.h"
#include "drum_mixer.h"
#include "drum_samples.h"
#include "metronome.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>
#include <string.h>
static const char *TAG = "DKIT_TAB";
typedef struct {
drum_id_t id;
lv_obj_t *pad;
} pad_t;
#define MAX_PADS 14
static pad_t s_pads[MAX_PADS];
static int s_npad = 0;
/* 60-Second Live Event Recorder */
#define MAX_REC_EVENTS 2048
#define MAX_REC_MS 60000u
typedef struct { uint32_t ms; drum_id_t id; uint8_t vel; } rec_evt_t;
static rec_evt_t s_rec[MAX_REC_EVENTS];
static int s_rec_n = 0;
static uint32_t s_rec_t0 = 0;
typedef enum { ST_IDLE, ST_REC, ST_PLAY } ui_state_t;
static volatile ui_state_t s_ui_state = ST_IDLE;
/* Console Bar Widget Pointers */
static lv_obj_t *s_kit_lbl = NULL;
static lv_obj_t *s_bpm_lbl = NULL;
static lv_obj_t *s_metro_btn = NULL;
static lv_obj_t *s_metro_dot = NULL;
static lv_obj_t *s_pat_btn = NULL;
static lv_obj_t *s_rec_btn = NULL;
static lv_obj_t *s_play_btn = NULL;
static lv_obj_t *s_vol_btn = NULL;
static lv_obj_t *s_vol_sld = NULL;
static lv_timer_t *s_rec_blink = NULL;
/* ββ Pad Touch Callback: Play Sound & Flash Drumhead ββ */
static void pad_ev(lv_event_t *e) {
lv_event_code_t c = lv_event_get_code(e);
pad_t *p = (pad_t *)lv_event_get_user_data(e);
if (!p) return;
if (c == LV_EVENT_PRESSED) {
drum_trigger(p->id, 100);
if (s_ui_state == ST_REC && s_rec_n < MAX_REC_EVENTS) {
uint32_t now = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
uint32_t t = now - s_rec_t0;
if (t <= MAX_REC_MS) {
s_rec[s_rec_n].ms = t;
s_rec[s_rec_n].id = p->id;
s_rec[s_rec_n].vel = 100;
s_rec_n++;
}
}
/* Instant visual phosphor flash */
lv_obj_set_style_bg_color(p->pad, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_bg_opa(p->pad, LV_OPA_COVER, 0);
} else if (c == LV_EVENT_RELEASED || c == LV_EVENT_PRESS_LOST) {
lv_obj_set_style_bg_color(p->pad, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(p->pad, LV_OPA_COVER, 0);
}
}
static void reg_pad(drum_id_t id, lv_obj_t *pad) {
if (s_npad >= MAX_PADS) return;
s_pads[s_npad].id = id;
s_pads[s_npad].pad = pad;
lv_obj_add_event_cb(pad, pad_ev, LV_EVENT_ALL, &s_pads[s_npad]);
s_npad++;
}
static inline void bare(lv_obj_t *o) {
lv_obj_set_style_pad_all(o, 0, 0);
lv_obj_set_style_shadow_width(o, 0, 0);
lv_obj_set_style_border_width(o, 0, 0);
lv_obj_set_style_outline_width(o, 0, 0);
lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLLABLE);
}
/* ββ Vector Cymbal Generator (Bronze Lathe Rings + Brass Bell) ββ */
static void make_cymbal(lv_obj_t *parent, drum_id_t id, int x, int y, int d, int num_rings) {
lv_obj_t *c = lv_obj_create(parent);
bare(c);
lv_obj_set_pos(c, x, y);
lv_obj_set_size(c, d, d);
lv_obj_set_style_radius(c, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(c, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(c, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(c, 2, 0);
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
/* Concentric lathe rings */
for (int i = 1; i <= num_rings; i++) {
int rd = (d * (i * 22 + 25)) / 100;
lv_obj_t *ring = lv_obj_create(c);
bare(ring);
lv_obj_set_size(ring, rd, rd);
lv_obj_center(ring);
lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_color(ring, LV_PIPBOY_COLOR_DIM, 0);
lv_obj_set_style_border_width(ring, 1, 0);
lv_obj_clear_flag(ring, LV_OBJ_FLAG_CLICKABLE);
}
/* Raised center brass bell */
int bell_d = d / 5 < 16 ? 16 : d / 5;
lv_obj_t *bell = lv_obj_create(c);
bare(bell);
lv_obj_set_size(bell, bell_d, bell_d);
lv_obj_center(bell);
lv_obj_set_style_radius(bell, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(bell, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_border_color(bell, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(bell, 1, 0);
lv_obj_clear_flag(bell, LV_OBJ_FLAG_CLICKABLE);
/* Center hole */
lv_obj_t *hole = lv_obj_create(bell);
bare(hole);
lv_obj_set_size(hole, 4, 4);
lv_obj_center(hole);
lv_obj_set_style_radius(hole, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(hole, LV_PIPBOY_COLOR, 0);
lv_obj_clear_flag(hole, LV_OBJ_FLAG_CLICKABLE);
reg_pad(id, c);
}
/* ββ Vector Shell Drum Generator (Chrome Rim + 4 Tuning Lugs) ββ */
static void make_drum(lv_obj_t *parent, drum_id_t id, int x, int y, int d, bool is_snare) {
lv_obj_t *dm = lv_obj_create(parent);
bare(dm);
lv_obj_set_pos(dm, x, y);
lv_obj_set_size(dm, d, d);
lv_obj_set_style_radius(dm, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(dm, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(dm, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(dm, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(dm, 3, 0);
lv_obj_add_flag(dm, LV_OBJ_FLAG_CLICKABLE);
/* 4 Perimeter Tuning Lugs */
int lug_w = 4, lug_h = 6;
lv_obj_t *lt = lv_obj_create(dm); bare(lt);
lv_obj_set_size(lt, lug_w, lug_h);
lv_obj_align(lt, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_bg_color(lt, LV_PIPBOY_COLOR, 0);
lv_obj_t *lb = lv_obj_create(dm); bare(lb);
lv_obj_set_size(lb, lug_w, lug_h);
lv_obj_align(lb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_set_style_bg_color(lb, LV_PIPBOY_COLOR, 0);
lv_obj_t *ll = lv_obj_create(dm); bare(ll);
lv_obj_set_size(ll, lug_h, lug_w);
lv_obj_align(ll, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_style_bg_color(ll, LV_PIPBOY_COLOR, 0);
lv_obj_t *lr = lv_obj_create(dm); bare(lr);
lv_obj_set_size(lr, lug_h, lug_w);
lv_obj_align(lr, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_style_bg_color(lr, LV_PIPBOY_COLOR, 0);
/* Inner Head Ring */
int in_d = (d * 82) / 100;
lv_obj_t *head = lv_obj_create(dm);
bare(head);
lv_obj_set_size(head, in_d, in_d);
lv_obj_center(head);
lv_obj_set_style_radius(head, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_color(head, LV_PIPBOY_COLOR_DIM, 0);
lv_obj_set_style_border_width(head, 1, 0);
lv_obj_clear_flag(head, LV_OBJ_FLAG_CLICKABLE);
/* Snare Strainer Wire Accent */
if (is_snare) {
lv_obj_t *sw = lv_obj_create(head); bare(sw);
lv_obj_set_size(sw, in_d - 16, 2);
lv_obj_center(sw);
lv_obj_set_style_bg_color(sw, LV_PIPBOY_COLOR_DIM, 0);
lv_obj_clear_flag(sw, LV_OBJ_FLAG_CLICKABLE);
}
reg_pad(id, dm);
}
/* ββ Vector Dual-Zone Hi-Hat (Split Semicircle: Closed Left / Open Right) ββ */
static void make_hihat(lv_obj_t *parent, int x, int y, int d) {
lv_obj_t *rim = lv_obj_create(parent);
bare(rim);
lv_obj_set_pos(rim, x, y);
lv_obj_set_size(rim, d, d);
lv_obj_set_style_radius(rim, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(rim, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_border_color(rim, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(2, 0);
lv_obj_set_style_clip_corner(rim, true, 0);
lv_obj_clear_flag(rim, LV_OBJ_FLAG_CLICKABLE);
/* Left Half: Closed Hi-Hat */
lv_obj_t *cl = lv_obj_create(rim); bare(cl);
lv_obj_set_size(cl, d / 2, d);
lv_obj_set_pos(cl, 0, 0);
lv_obj_set_style_radius(cl, 0, 0);
lv_obj_set_style_bg_color(cl, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(cl, LV_OPA_COVER, 0);
lv_obj_add_flag(cl, LV_OBJ_FLAG_CLICKABLE);
/* Right Half: Open Hi-Hat */
lv_obj_t *op = lv_obj_create(rim); bare(op);
lv_obj_set_size(op, d / 2, d);
lv_obj_set_pos(op, d / 2, 0);
lv_obj_set_style_radius(op, 0, 0);
lv_obj_set_style_bg_color(op, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(op, LV_OPA_COVER, 0);
lv_obj_add_flag(op, LV_OBJ_FLAG_CLICKABLE);
/* Vertical Divider */
lv_obj_t *div = lv_obj_create(rim); bare(div);
lv_obj_set_size(div, 2, d);
lv_obj_set_pos(div, d / 2 - 1, 0);
lv_obj_set_style_bg_color(div, LV_PIPBOY_COLOR, 0);
lv_obj_clear_flag(div, LV_OBJ_FLAG_CLICKABLE);
/* Center Bell */
int bell_d = d / 5 < 16 ? 16 : d / 5;
lv_obj_t *bell = lv_obj_create(rim); bare(bell);
lv_obj_set_size(bell, bell_d, bell_d);
lv_obj_center(bell);
lv_obj_set_style_radius(bell, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(bell, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_border_color(bell, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(bell, 1, 0);
lv_obj_clear_flag(bell, LV_OBJ_FLAG_CLICKABLE);
reg_pad(DRUM_HIHAT_CLOSED, cl);
reg_pad(DRUM_HIHAT_OPEN, op);
}
/* ββ Vector Auxiliary Bar Trigger (Pill Shape with Rubber Strike Rib) ββ */
static void make_trigger_pad(lv_obj_t *parent, drum_id_t id, int x, int y, int w, int h) {
lv_obj_t *tg = lv_obj_create(parent);
bare(tg);
lv_obj_set_pos(tg, x, y);
lv_obj_set_size(tg, w, h);
lv_obj_set_style_radius(tg, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(tg, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(tg, LV_OPA_COVER, 0);
lv_obj_set_style_border_color(tg, LV_PIPBOY_COLOR, 0);
lv_obj_set_style_border_width(tg, 2, 0);
lv_obj_add_flag(tg, LV_OBJ_FLAG_CLICKABLE);
/* Horizontal Rubber Rib */
lv_obj_t *rib = lv_obj_create(tg); bare(rib);
lv_obj_set_size(rib, w - 16, 2);
lv_obj_center(rib);
lv_obj_set_style_bg_color(rib, LV_PIPBOY_COLOR_DIM, 0);
lv_obj_clear_flag(rib, LV_OBJ_FLAG_CLICKABLE);
reg_pad(id, tg);
}
/* ββ Assembly: Drum Stage & Bottom Control Console ββ */
void pipboy_dkit_tab_build(lv_obj_t *parent) {
bare(parent);
lv_obj_set_style_bg_color(parent, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, 0);
/* 1. Main Drum Stage (800 x 340) */
lv_obj_t *stage = lv_obj_create(parent);
bare(stage);
lv_obj_set_size(stage, 800, 340);
lv_obj_set_pos(stage, 0, 0);
lv_obj_set_style_bg_color(stage, LV_PIPBOY_BLACK, 0);
lv_obj_set_style_bg_opa(stage, LV_OPA_COVER, 0);
/* Cymbals */
make_cymbal(stage, DRUM_CRASH, 42, 8, 114, 2);
make_cymbal(stage, DRUM_RIDE, 600, 8, 134, 3);
make_cymbal(stage, DRUM_CRASH, 646, 168, 108, 2);
/* Dual Hi-Hat */
make_hihat(stage, 22, 138, 116);
/* Auxiliary Bar Triggers */
make_trigger_pad(stage, DRUM_CLAP, 40, 268, 78, 38);
make_trigger_pad(stage, DRUM_RIDE, 340, 292, 78, 38);
/* Shell Drums */
make_drum(stage, DRUM_TOM_HIGH, 230, 14, 104, false);
make_drum(stage, DRUM_TOM_MID, 426, 14, 104, false);
make_drum(stage, DRUM_SNARE, 158, 148, 120, true);
make_drum(stage, DRUM_KICK, 310, 142, 136, false);
make_drum(stage, DRUM_TOM_FLOOR, 484, 148, 124, false);
/* 2. Bottom Phosphor Console Bar (800 x 44) */
/* ... Controls for Kit, BPM, Metronome, Backing Beat, Rec, Play, Volume ... */
}CODEBREAKDOWN
Now let's get into the code! The main interface is implemented in pipboy_dkit_tab.c (also referred to as drum_ui.c in the standalone version). While the file contains quite a bit of code, the logic is relatively straightforward. We create reusable functions for each instrument, position them on the screen, and connect them to the audio engine.
Registering the Drum Pads
First, we need a way to associate each graphical element with the sound it should trigger. For this, we use a simple structure that stores the drum ID and a pointer to its LVGL object.
typedef struct {
drum_id_t id;
lv_obj_t *pad;
} pad_t;
#define MAX_PADS 14
static pad_t s_pads[MAX_PADS];
static int s_npad = 0;Each pad is assigned a unique drum ID, allowing the software to identify which instrument was tapped. The array stores references to the registered pads, with a maximum capacity of 14 entries.
We then use a helper function to register each pad and attach its touch event callback.
static void reg_pad(drum_id_t id, lv_obj_t *pad) {
if (s_npad >= MAX_PADS) return;
s_pads[s_npad].id = id;
s_pads[s_npad].pad = pad;
lv_obj_add_event_cb(
pad,
pad_ev,
LV_EVENT_ALL,
&s_pads[s_npad]
);
s_npad++;
}This keeps the implementation modular. Instead of writing separate touch-handling code for every instrument, we can reuse the same callback for all registered pads.
Creating the Cymbals
The make_cymbal() function creates the cymbals using circular LVGL objects. Each cymbal has a dark background, a bright outer rim, and concentric rings that resemble the grooves found on a real cymbal.
static void make_cymbal(
lv_obj_t *parent,
drum_id_t id,
int x, int y,
int d,
int num_rings
)The parameters control the cymbal's position, diameter, assigned sound, and number of decorative rings.
The rings are generated using a loop, which calculates their diameters relative to the cymbal's overall size. A smaller circular element is then added at the centre to represent the cymbal bell.
Finally, the cymbal is registered as an interactive pad.
Creating the Shell Drums
The snare, kick, and toms are generated using another reusable function called make_drum().
static void make_drum(
lv_obj_t *parent,
drum_id_t id,
int x, int y,
int d,
bool is_snare
)Similar to the cymbal generator, this function creates a circular drum with a dark background and an illuminated outer rim. We also add four small tuning lugs around the perimeter and an inner ring to give the drumhead more detail.
The is_snare parameter lets us add an extra visual element to the snare drum, distinguishing it from the other drums.
Once the graphical elements are created, the main drum object is registered with the touch handler. This means the entire drumhead acts as an interactive surface.
Creating the Dual-Zone Hi-Hat
The hi-hat required a slightly different approach because we wanted to support both open and closed hi-hat sounds.
Instead of treating it as a single interactive object, we divide it into two separate touch zones.
/* Left half: Closed hi-hat */
reg_pad(DRUM_HIHAT_CLOSED, cl);
/* Right half: Open hi-hat */
reg_pad(DRUM_HIHAT_OPEN, op);The left half triggers the closed hi-hat sound, while the right half triggers the open hi-hat sound. A central divider visually separates the two zones, and an additional circular element represents the bell.
This gives us two distinct sounds within the footprint of a single instrument, making the virtual kit more versatile without taking up additional screen space.
Adding Auxiliary Trigger Pads
We also created a make_trigger_pad() function for auxiliary sounds.
These pads use elongated, rounded rectangles with an illuminated outline and a small horizontal accent in the centre.
The function accepts the position, width, height, and drum ID as parameters, allowing us to reuse the same design for different auxiliary triggers.
Like the main drums and cymbals, each trigger pad is registered with the same touch event handler.
Handling Touch Events and Playing Sounds
Now comes the important part: making the interface playable.
Whenever a user presses a drum or cymbal, LVGL calls the pad_ev() event handler. The handler retrieves the drum ID associated with that particular object and passes it to the audio engine.
if (c == LV_EVENT_PRESSED) {
drum_trigger(p->id, 100);
lv_obj_set_style_bg_color(
p->pad,
LV_PIPBOY_COLOR,
0
);
lv_obj_set_style_bg_opa(
p->pad,
LV_OPA_COVER,
0
);
}The drum_trigger() function plays the corresponding instrument sound. At the same time, the drumhead changes to the active Pip-Boy theme colour, producing an instant visual flash whenever a note is played.
When the user releases the screen, or the touch is interrupted, the handler restores the drumhead's background to black.
else if (
c == LV_EVENT_RELEASED ||
c == LV_EVENT_PRESS_LOST
) {
lv_obj_set_style_bg_color(
p->pad,
LV_PIPBOY_BLACK,
0
);
lv_obj_set_style_bg_opa(
p->pad,
LV_OPA_COVER,
0
);
}This small interaction makes the interface feel much more responsive and reinforces the classic Pip-Boy phosphor display aesthetic.
The same event handler also records drum hits when recording mode is active, storing the event timestamp, instrument ID, and velocity for later playback.
Assembling the Complete Interface
Finally, pipboy_dkit_tab_build() brings all the individual elements together.
The function creates the main drum stage, sets its dimensions, and positions each instrument using its coordinates and size.
/* Cymbals */
make_cymbal(stage, DRUM_CRASH, 42, 8, 114, 2);
make_cymbal(stage, DRUM_RIDE, 600, 8, 134, 3);
/* Dual-zone hi-hat */
make_hihat(stage, 22, 138, 116);
/* Shell drums */
make_drum(stage, DRUM_TOM_HIGH, 230, 14, 104, false);
make_drum(stage, DRUM_TOM_MID, 426, 14, 104, false);
make_drum(stage, DRUM_SNARE, 158, 148, 120, true);
make_drum(stage, DRUM_KICK, 310, 142, 136, false);
make_drum(stage, DRUM_TOM_FLOOR, 484, 148, 124, false);By adjusting these coordinates and dimensions, we can control the layout of the entire drum kit. Each instrument is drawn using the functions we defined earlier, so changing its size or position doesn't require rewriting its underlying design.
The interface also reserves space for a bottom control bar containing controls for the drum kit selection, BPM, metronome, backing beat, recording, playback, and volume.
AUDIO ENGINEThe UI is only half the story. To make DrumKit feel like a real instrument, we needed an audio engine capable of playing multiple drum sounds simultaneously without noticeable latency or audio glitches.
Instead of reading sound files from a microSD card every time a drum is tapped, we preload the audio samples into high-speed PSRAM during initialization. This allows the engine to access the samples directly from memory, keeping playback responsive even when multiple instruments are triggered in quick succession.
The sound library covers the main instruments in our virtual kit:
- Kick Drum: Deep, punchy bass sounds.
- Snare Drum: Sharp, snappy percussive hits.
- Hi-Hats: Separate open and closed hi-hat sounds.
- Toms and Cymbals: Different tones for fills, accents, and rhythms.
/*
* drum_mixer.c β Lock-Free Core 1 Audio Mixer Engine (44.1kHz Stereo)
*/
#include "drum_mixer.h"
#include "drum_samples.h"
#include "esp_codec_dev.h"
#include "bsp/esp-bsp.h"
#define MAX_VOICES 16
#define MIX_BUFFER_FRAMES 256
typedef struct {
const int16_t *samples;
uint32_t num_frames;
uint32_t current_frame;
int32_t volume_gain;
bool is_stereo;
volatile bool active;
} voice_t;
static voice_t s_voices[MAX_VOICES];
static portMUX_TYPE s_voice_mux = portMUX_INITIALIZER_UNLOCKED;
/* Soft-knee polynomial saturation: clean below +/-28000, analog compressor above */
static inline int16_t clamp16_fast(int32_t v) {
if (v > 28000) {
int32_t diff = v - 28000;
int32_t comp = 28000 + (diff * 4767) / (diff + 4767);
return (int16_t)(comp > 32767 ? 32767 : comp);
} else if (v < -28000) {
int32_t diff = -v - 28000;
int32_t comp = -(28000 + (diff * 4767) / (diff + 4767));
return (int16_t)(comp < -32768 ? -32768 : comp);
}
return (int16_t)v;
}
void drum_trigger(drum_id_t id, uint8_t velocity) {
const drum_sample_t *s = drum_samples_get(drum_get_kit(), id);
if (!s || !s->samples) return;
portENTER_CRITICAL(&s_voice_mux);
int target = 0;
for (int i = 0; i < MAX_VOICES; i++) {
if (!s_voices[i].active) { target = i; break; }
}
s_voices[target].samples = s->samples;
s_voices[target].num_frames = s->num_frames;
s_voices[target].current_frame = 0;
s_voices[target].is_stereo = s->is_stereo;
/* 1.875x digital amplification (+5.5 dB boost) */
s_voices[target].volume_gain = ((int32_t)velocity * 480) / 100;
s_voices[target].active = true;
portEXIT_CRITICAL(&s_voice_mux);
if (s_mixer_task_handle) xTaskNotifyGive(s_mixer_task_handle);
}
/* Pinned to Core 1 at Priority 10 */
static void audio_mixer_task(void *pvParameters) {
int16_t dma_buf[MIX_BUFFER_FRAMES * 2];
voice_t v_snap[MAX_VOICES];
while (1) {
/* Take critical section ONCE per buffer (~5.8ms) to snapshot voices */
portENTER_CRITICAL(&s_voice_mux);
for (int v = 0; v < MAX_VOICES; v++) v_snap[v] = s_voices[v];
portEXIT_CRITICAL(&s_voice_mux);
/* Render buffer completely lock-free without starving Core 0 */
for (int frame = 0; frame < MIX_BUFFER_FRAMES; frame++) {
int32_t mix_l = 0, mix_r = 0;
for (int v = 0; v < MAX_VOICES; v++) {
if (!v_snap[v].active) continue;
uint32_t cur = v_snap[v].current_frame;
if (cur >= v_snap[v].num_frames) { v_snap[v].active = false; continue; }
int32_t s_l = (int32_t)v_snap[v].samples[cur];
mix_l += (s_l * v_snap[v].volume_gain) >> 8;
mix_r += (s_l * v_snap[v].volume_gain) >> 8;
v_snap[v].current_frame++;
}
dma_buf[frame * 2] = clamp16_fast(mix_l);
dma_buf[frame * 2 + 1] = clamp16_fast(mix_r);
}
/* Commit progress back to main voice array once per buffer */
portENTER_CRITICAL(&s_voice_mux);
for (int v = 0; v < MAX_VOICES; v++) {
if (s_voices[v].active && s_voices[v].samples == v_snap[v].samples) {
s_voices[v].current_frame = v_snap[v].current_frame;
if (s_voices[v].current_frame >= s_voices[v].num_frames) s_voices[v].active = false;
}
}
portEXIT_CRITICAL(&s_voice_mux);
esp_codec_dev_write(s_spk_codec, dma_buf, sizeof(dma_buf));
}
}Setting Up the Audio Mixer
The audio engine lives in drum_mixer.c. It uses a voice-based mixing system that supports up to 16 simultaneous audio voices.
#define MAX_VOICES 16
#define MIX_BUFFER_FRAMES 256
typedef struct {
const int16_t *samples;
uint32_t num_frames;
uint32_t current_frame;
int32_t volume_gain;
bool is_stereo;
volatile bool active;
} voice_t;
static voice_t s_voices[MAX_VOICES];Each voice represents an individual sound currently being played. The structure keeps track of the sample data, its total length, the current playback position, the volume gain, and whether the voice is active.
This approach allows the mixer to combine several drum hits into a single audio stream.
For example, you can hit the snare, kick, and cymbal almost simultaneously without the engine having to finish playing one sound before starting another.
Triggering a Drum Sound
Whenever a drum is tapped, the UI calls drum_trigger() with the corresponding drum ID.
void drum_trigger(drum_id_t id, uint8_t velocity) {
const drum_sample_t *s =
drum_samples_get(drum_get_kit(), id);
if (!s || !s->samples) return;
// Allocate a voice and initialise playback.
}The function retrieves the appropriate sample from the currently selected drum kit and prepares a voice for playback. It resets the playback position to the beginning of the sample and applies a gain based on the requested velocity.
Once the voice is marked active, the mixer task is notified that new audio is available.
This keeps the UI interaction simple: the touchscreen identifies the instrument, while the audio engine handles the actual playback.
Mixing Multiple Sounds
The mixer processes audio in small blocks of 256 frames. At a 44.1 kHz sample rate, each block represents approximately 5.8 milliseconds of audio.
Before processing a block, the mixer takes a snapshot of the active voices. It then combines their sample values frame by frame, accumulating the contributions from each active voice.
for (int frame = 0; frame < MIX_BUFFER_FRAMES; frame++) {
int32_t mix_l = 0;
int32_t mix_r = 0;
for (int v = 0; v < MAX_VOICES; v++) {
if (!v_snap[v].active) continue;
// Read the current sample and mix it.
}
dma_buf[frame * 2] = clamp16_fast(mix_l);
dma_buf[frame * 2 + 1] = clamp16_fast(mix_r);
}The mixer uses 32-bit accumulators while combining the samples, giving it more room to handle the combined amplitude of multiple instruments before converting the result back to 16-bit audio.
Once the block has been rendered, it is sent to the audio codec for playback.
Keeping Audio Smooth
Mixing several sounds can cause the combined signal to exceed the maximum range of a 16-bit audio sample. Simply clipping the signal can introduce harsh distortion.
To address this, we use a function called clamp16_fast(), which applies a soft-knee saturation curve when the mixed signal exceeds a defined threshold. This helps control peaks and makes the output less prone to harsh clipping.
The mixer also minimises the time spent inside critical sections. It briefly locks access to the shared voice data, copies the state into a local snapshot, and performs the bulk of the mixing outside the critical section.
This reduces contention between the audio task and other parts of the application.
Prioritising Audio Processing
The audio mixer runs in a dedicated FreeRTOS task pinned to Core 1 at priority 10. This gives audio processing a dedicated execution context and helps it keep up with the incoming playback workload.
The task continuously mixes the active voices into an output buffer and sends that buffer to the speaker codec.
Combined with preloaded samples, short processing blocks, and concurrent voice playback, this architecture helps DrumKit remain responsive when multiple drums are played in quick succession.
The result is an audio engine designed to keep up with the touchscreen, allowing you to play the virtual drum kit without the sluggish response you'd expect from an interface that loads sounds on demand.
TOUCH DRIVER: ENABLING FIVE-FINGER MULTITOUCH
When we first tested DrumKit, we ran into a limitation with the touchscreen driver: only one touch point was being registered at a time. This was a problem for a drum kit, where you might want to hit the hi-hat with one finger and the snare with another, or play several drums simultaneously.
The issue came from Espressif's LVGL touch adapter, esp_lvgl_adapter, which was configured with MAX_TOUCH_POINTS 1. As a result, simultaneous touches were not being handled as required.
Patching the Touch Driver
To fix this, we modified esp_lv_adapter_input_touch.c to support five simultaneous touch points using the GT911 touchscreen controller.
Instead of treating the touchscreen as a single input device, we registered five separate lv_indev_t input devices. On each input update, the driver reads the coordinates of all five fingers from the GT911's hardware registers in a single IΒ²C transaction and caches the results.
The individual LVGL input devices then read their respective coordinates from this shared cache. This avoids unnecessary IΒ²C transactions for each finger while allowing all five touch points to be processed during the same update cycle.
With this modification, DrumKit can register up to five simultaneous touches, allowing us to play with both hands and trigger multiple instruments at once.
This was a crucial improvement because multitouch isn't just an additional feature for a virtual drum kit; it's essential to making the experience feel natural. We can now hit the kick, snare, and hi-hat in quick succession without the touchscreen limiting you to a single finger at a time.
COMBINING DRUMKIT WITH PIPBOY
Once DrumKit was running smoothly as a standalone project, the next step was to integrate it directly into the main Fallout Pip-Boy project.
Inside pipboy_ui.c, we replaced the old Radio tab with a new DKit tab. This lets DrumKit run as part of the existing Pip-Boy interface rather than as a separate application.
/* In pipboy_ui.c: registering the tabs */
lv_obj_t *tab_stat = lv_tabview_add_tab(s_tabview, "Status");
lv_obj_t *tab_skills = lv_tabview_add_tab(s_tabview, "Skills");
lv_obj_t *tab_watch = lv_tabview_add_tab(s_tabview, "Watch");
lv_obj_t *tab_dkit = lv_tabview_add_tab(s_tabview, "DKit");
lv_obj_t *tab_map = lv_tabview_add_tab(s_tabview, "Map");
pipboy_dkit_tab_build(tab_dkit);
/* Sub-nav label array */
const char *tabs[] = {"Status", "Skills", "Watch", "DKit", "Map"};The integration also meant bringing DrumKit into the Pip-Boy's existing theme system. Tapping the Vault Boy portrait on the Status tab cycles between three colour schemes: New Vegas Amber, Vault-Tec Green, and Institute Blue. DrumKit follows along automatically, recolouring its drumheads, lathed rings, rims, and bottom console in real time to match the selected theme.
We also wanted the interface to retain that classic Pip-Boy feedback when navigating between tabs. To achieve this, clicks on the other tabs are routed through radio_player_click(), which plays a short, closed-hi-hat-style sound. It's a small detail, but it helps make the entire interface feel more responsive and gives the different sections a consistent audio touch.
You can flash and run DrumKit independently for testing, or flash the complete Fallout Pip-Boy firmware with DrumKit integrated into the main interface.
Flash the Standalone DrumKit
Open your terminal and run the following commands to navigate to the DrumKit project directory, load the ESP-IDF environment, and flash the firmware.
cd /Users/mac/Documents/FalloutPipboy/drumkit
source /Users/mac/esp/esp-idf/export.sh
idf.py -p /dev/cu.usbmodem5B5E0698681 flash monitorFlash the Complete Fallout Pip-Boy
To flash the full Pip-Boy firmware, including the integrated DrumKit, navigate to the main project directory and run:
cd /Users/mac/Documents/FalloutPipboy/pipboy
source /Users/mac/esp/esp-idf/export.sh
idf.py -p /dev/cu.usbmodem5B5E0698681 flash monitorPS: These commands use the local paths and serial port from the development setup. Update them to match your ESP-IDF installation, project location, and connected board's serial port.
Once the firmware was flashed successfully, we installed the ESP32-P4 board back inside the Pip-Boy enclosure and reconnected the battery. With everything in place, the device was ready to go!
RESULTAnd here's the end result of the build: our Fallout Pip-Boy, now loaded with a brand-new electronic drum kit! What started as a standalone virtual drum kit is now fully integrated into the Pip-Boy interface, and it's honestly pretty fun to play around with.
The DrumKit interface includes cymbals, a snare, a kick drum, high and mid toms, a floor tom, and auxiliary trigger pads. The hi-hat even has separate closed and open zones, giving you more control over the sounds you can trigger. Every time you tap a drum, it flashes with that classic phosphor effect, making the whole experience feel right at home in the Fallout universe.
But there's more to it than just tapping virtual drums. You can play built-in beats and increase or decrease their playback speed to suit your rhythm. There's also a record and playback feature, so you can record your own drumming and play it back later. And if things get a little too loud, you can adjust the volume directly from the interface.
OVERVIEWThis project is essentially the same Pip-Boy AR Mark 6000 I built earlier, but with one fun new addition: a fully integrated electronic drum kit! It's a small change to the existing UI, but it makes the whole Pip-Boy a lot more fun to use.
And that's the beauty of building a custom interface like this. There's still plenty of room to experiment and add new features. Maybe I'll integrate a few sensors, add a game, or even connect a camera module to the Waveshare ESP32-P4 development board and turn this thing into a Pip-Cam. The possibilities are endless, and I already have a few ideas in mind!
For now, though, this build is complete. You'll find everything you need to recreate the project in this article, including the code, project files, and other relevant details.
A special thanks to Waveshare for providing the hardware used in this build and supporting the project. If you run into any issues while recreating it, feel free to leave a comment below, and I'll do my best to help you out.
That's all for this build. Thanks for following along, and I'll see you in the next project!
Peace!






_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)






Comments