Dynamic LED Hourglass with Sound Effects - ESP32 & 16x16 Color Matrix Tutorial
This is a visually impressive hourglass simulation project, and despite its simplicity, there are many possibilities for modifying most parameters.
An hourglass is a device used to measure the passage of time. It consists of two glass bulbs connected by a narrow passage, allowing a substance (typically fine sand) to flow from the upper bulb to the lower one at a consistent rate.

Once all the sand has flowed to the lower bulb, the hourglass is flipped to measure time again. The time it takes for the sand to flow completely from one bulb to the other depends on the size of the hourglass and the amount of sand. Recently, in one of my previous projects I presented you with a way to create a virtual hourglass on a small OLED display.
In this project, quite the opposite, I will present you with another simple way to make such a device, but now on a huge color "Display" which actually represents a 16x16 LED matrix with LEDs, each of which contains a WS2812b LED chip, so that each LED can be controlled individually.

This project is sponsored by Altium 365 . Altium 365 is a cloud-based platform designed for electronics design and engineering. It provides a suite of tools for PCB design tools, including Requipments management, Supply chain, Library managment, ECAD connectivity, Co-design and integration, and manufacturing portal.

The device is really very simple to make and consists of a few components.
- ESP32 DEV Board
- 16x16 led matrix with WS2812bLeds
- Tilt switch
- and small speaker
The components are built into a suitable box made of PVC material that I made for a previous project of mine, and on the front of the matrix there is a 3D printed grid for which you can find the .STL file at the end of this text

To achieve a more realistic simulation, I used a tilt switch, so by rotating the clock 180 degrees the countdown starts over. If we look at the code, we will see that it is designed in a way that allows us to easily change almost all parameters, starting from the number of particles, through the flow rate, the brightness of the LEDs, and even defining the colors.

In particular, we can change the color of the hourglass wall, the sand, the environment around it, the internal empty space, as well as the color of the numbers. The numbers on both sides of the hourglass show the remaining time in seconds. I also added simple sound effects to start and end the countdown, as well as a short sound to indicate each second that passes.
Let's see from the beginning how this looks in reality. Immediately after switching on, a sound effect is emitted that indicates the start of the countdown, and with each passing second a short beep. In the upper bowl there are 30 grains of sand that should run out in 1 minute, so each grain runs out to the lower bowl in two seconds.

The grains in the lower bowl are arranged randomly, which is another approximation to a real simulation. After one minute has passed, a sound effect is emitted that indicates the end of the countdown, and now all the sand particles are in the lower bowl. If I now rotate the hourglass 180 degrees, the countdown starts from the beginning.
And finally, a short conclusion. This is a visually impressive hourglass simulation project, and despite its simplicity, there are many possibilities for modifying most parameters.

/*ESP21 Hourglass on 16x16 Matrix WS2812b
by mircemk, April 2025
*/
#include <FastLED.h>
#define LED_PIN 5
#define NUM_LEDS 256
#define BRIGHTNESS 64
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
#define TILT_PIN 4 // D4 pin for tilt switch
#define BUZZER_PIN 2 // Choose an available digital pin for the buzzer
CRGB leds[NUM_LEDS];
// Colors
CRGB BLACK = CRGB(0, 0, 0);
CRGB MAGENTA = CRGB(255, 0, 255);
CRGB YELLOW = CRGB(255, 255, 0);
CRGB WHITE = CRGB(255, 255, 255); // Color for digits
CRGB PALE_PURPLE = CRGB(0, 0, 0); // Very dim purple for outside dots
CRGB PALE_RED = CRGB(7,15, 15); // Very dim red for inside dots
// Animation timing
const unsigned long PARTICLE_FALL_TIME = 2000; // 2 seconds per particle
const int TOTAL_PARTICLES = 30;
const unsigned long RESTART_DELAY = 60000; // 1 minute
// Grid dimensions
const int GRID_WIDTH = 16;
const int GRID_HEIGHT = 16;
// Digit display positions (7th row from top, 3 pixels from edges)
const int LEFT_DIGIT_X = 0; // Changed from 3 to 0 (far left)
const int RIGHT_DIGIT_X = 13; // Changed from 10 to 13 (far right)
const int DIGIT_Y = 6; // Keep the same vertical position
const int START_TONES[] = {300, 600, 900}; // Starting sequence frequencies
const int TICK_TONE = 100; // Countdown tick frequency
const int END_TONES[] = {900, 600, 300}; // Ending sequence frequencies
const int START_END_TONE_DURATION = 200; // Duration for start/end tones in ms
const int TICK_TONE_DURATION = 50; // Duration for tick tone in ms
bool displayRotated = false; // Track if display is rotated
unsigned long lastTiltCheck = 0; // Debouncing
const unsigned long TILT_CHECK_DELAY = 50; // Check tilt every 50ms
unsigned long lastSecond = 60; // Track last second for tone
bool startTonesPlayed = false; // Track if start tones have been played
bool endTonesPlayed = false; // Track if end tones have been played
// Tracking variables
unsigned long startTime = 0;
unsigned long currentTime = 0;
int particlesFallen = 0;
bool animationComplete = false;
// Use a 1D array to track sand (1=sand, 0=no sand)
byte sandState[NUM_LEDS];
// Falling particle
bool fallingParticle = false;
uint8_t fallingParticleX = 0;
uint8_t fallingParticleY = 0;
unsigned long fallingStartTime = 0;
// Convert x,y coordinates to LED index (assuming serpentine layout)
uint16_t XY(uint8_t x, uint8_t y) {
uint16_t i;
if (displayRotated) {
// If rotated, flip both x and y coordinates
x = GRID_WIDTH - 1 - x;
y = GRID_HEIGHT - 1 - y;
}
if(y & 0x01) { // Odd rows run backwards
uint8_t reverseX = (GRID_WIDTH-1) - x;
i = (y * GRID_WIDTH) + reverseX;
} else { // Even rows run forwards
i = (y * GRID_WIDTH) + x;
}
return i;
}
// 5x3 Font Data for digits 0-9 (full 5x3 matrix design)
const byte DIGITS[10][5][3] = {
{ // 0
{1,1,1},
{1,0,1},
{1,0,1},
{1,0,1},
{1,1,1}
},
{ // 1
{0,1,0},
{1,1,0},
{0,1,0},
{0,1,0},
{1,1,1}
},
{ // 2
{1,1,1},
{0,0,1},
{1,1,1},
{1,0,0},
{1,1,1}
},
{ // 3
{1,1,1},
{0,0,1},
{1,1,1},
{0,0,1},
{1,1,1}
},
{ // 4
{1,0,1},
{1,0,1},
{1,1,1},
{0,0,1},
{0,0,1}
},
{ // 5
{1,1,1},
{1,0,0},
{1,1,1},
{0,0,1},
{1,1,1}
},
{ // 6
{1,1,1},
{1,0,0},
{1,1,1},
{1,0,1},
{1,1,1}
},
{ // 7
{1,1,1},
{0,0,1},
{0,1,0},
{1,0,0},
{1,0,0}
},
{ // 8
{1,1,1},
{1,0,1},
{1,1,1},
{1,0,1},
{1,1,1}
},
{ // 9
{1,1,1},
{1,0,1},
{1,1,1},
{0,0,1},
{1,1,1}
}
};
// drawDigit function to handle 5x3 digits
void drawDigit(int digit, int xPos, int yPos, CRGB color) {
for (int y = 0; y < 5; y++) { // Changed from 6 to 5
for (int x = 0; x < 3; x++) {
if (DIGITS[digit][y][x]) {
// Reverse the x-coordinate by drawing from right to left
leds[XY(xPos + (2 - x), yPos + y)] = color;
}
}
}
}
// Function to draw countdown number
void drawCountdown(int seconds) {
int tens = seconds / 10;
int ones = seconds % 10;
// Draw tens digit on the right
drawDigit(tens, RIGHT_DIGIT_X, DIGIT_Y, WHITE);
// Draw ones digit on the left
drawDigit(ones, LEFT_DIGIT_X, DIGIT_Y, WHITE);
}
// Check if a position is within the hourglass container
bool isInsideHourglass(uint8_t x, uint8_t y) {
// Top half
if (y <= 7) {
if (y == 0 && x >= 1 && x <= 14) return true;
if (y >= 1 && y <= 3 && x >= 2 && x <= 13) return true;
if (y == 4 && x >= 3 && x <= 12) return true;
if (y == 5 && x >= 4 && x <= 11) return true;
if (y == 6 && x >= 5 && x <= 10) return true;
if (y == 7 && x >= 6 && x <= 9) return true;
}
// Bottom half
else {
if (y == 8 && x >= 6 && x <= 9) return true;
if (y == 9 && x >= 5 && x <= 10) return true;
if (y == 10 && x >= 4 && x <= 11) return true;
if (y == 11 && x >= 3 && x <= 12) return true;
if (y >= 12 && y <= 14 && x >= 2 && x <= 13) return true;
if (y == 15 && x >= 1 && x <= 14) return true;
}
return false;
}
// Check if a position is part of the hourglass outline
bool isHourglassOutline(uint8_t x, uint8_t y) {
// Top base (row 0)
if (y == 0 && x >= 1 && x <= 14) return true;
// Vertical walls - top half
if (y >= 1 && y <= 3 && (x == 2 || x == 13)) return true;
if (y == 4 && (x == 3 || x == 12)) return true;
if (y == 5 && (x == 4 || x == 11)) return true;
if (y == 6 && (x == 5 || x == 10)) return true;
if (y == 7 && (x == 6 || x == 9)) return true;
// Neck - only the sides, keeping the middle open
if (y == 7 && (x == 7 || x == 8)) return false;
if (y == 8 && (x == 7 || x == 8)) return false;
// Vertical walls - bottom half
if (y == 8 && (x == 6 || x == 9)) return true;
if (y == 9 && (x == 5 || x == 10)) return true;
if (y == 10 && (x == 4 || x == 11)) return true;
if (y == 11 && (x == 3 || x == 12)) return true;
if (y >= 12 && y <= 14 && (x == 2 || x == 13)) return true;
// Bottom base (row 15)
if (y == 15 && x >= 1 && x <= 14) return true;
return false;
}
// Check if a position is in the neck area
bool isNeckPosition(uint8_t x, uint8_t y) {
return ((y == 7 || y == 8) && (x == 7 || x == 8));
}
// Initialize the hourglass with sand particles
// Initialize the hourglass with sand particles
void initHourglass() {
// First, set the background colors instead of clearing to black
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
// Inside hourglass - pale red background
sandState[XY(x, y)] = 0; // Initialize as empty
leds[XY(x, y)] = PALE_RED;
} else if (!isInsideHourglass(x, y)) {
// Outside hourglass - pale purple background
sandState[XY(x, y)] = 0;
leds[XY(x, y)] = PALE_PURPLE;
} else {
// Areas that will be outline
sandState[XY(x, y)] = 0;
leds[XY(x, y)] = BLACK;
}
}
}
int particleCount = 0;
// First add 2 particles in the upper neck area (y=7)
sandState[XY(7, 7)] = 1; // First neck particle
sandState[XY(8, 7)] = 1; // Second neck particle
particleCount = 2;
// Fill the remaining 28 particles in the top half
for (uint8_t y = 3; y <= 7; y++) {
for (uint8_t x = 0; x < GRID_WIDTH && particleCount < TOTAL_PARTICLES; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) &&
!(x == 7 && y == 7) && !(x == 8 && y == 7) && // Skip the neck positions we already filled
sandState[XY(x, y)] == 0) {
sandState[XY(x, y)] = 1; // Add sand
particleCount++;
}
}
}
// Draw initial state
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW; // Draw sand particles
} else if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA; // Draw outline
}
}
}
FastLED.show(); // Show the initial state
particlesFallen = 0;
animationComplete = false;
}
// Find a sand particle in the top container to drop
bool findSandParticleToRemove(uint8_t* outX, uint8_t* outY) {
for (uint8_t y = 3; y <= 7; y++) {
int particleXPositions[GRID_WIDTH];
int particleYPositions[GRID_WIDTH];
int count = 0;
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) && sandState[XY(x, y)] == 1) {
particleXPositions[count] = x;
particleYPositions[count] = y;
count++;
}
}
if (count > 0) {
int randomIndex = random(count);
*outX = particleXPositions[randomIndex];
*outY = particleYPositions[randomIndex];
sandState[XY(*outX, *outY)] = 0; // Remove this particle
return true;
}
}
return false;
}
// Find a position in the bottom container
bool findPositionInBottomContainer(uint8_t* outX, uint8_t* outY) {
for (uint8_t y = 15; y >= 9; y--) {
int availableSpots[GRID_WIDTH];
int count = 0;
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y) && sandState[XY(x, y)] == 0) {
availableSpots[count] = x;
count++;
}
}
if (count > 0) {
int randomIndex = random(count);
*outX = availableSpots[randomIndex];
*outY = y;
return true;
}
}
return false;
}
// Start a new falling particle
void startNewFallingParticle() {
uint8_t startX, startY;
if (!findSandParticleToRemove(&startX, &startY)) {
fallingParticle = false;
return;
}
fallingParticleX = startX;
fallingParticleY = startY;
fallingParticle = true;
fallingStartTime = millis();
particlesFallen++;
}
// Update falling particle position
void updateFallingParticle() {
if (!fallingParticle) return;
unsigned long elapsed = millis() - fallingStartTime;
if (elapsed >= PARTICLE_FALL_TIME) {
fallingParticle = false;
uint8_t endX, endY;
if (findPositionInBottomContainer(&endX, &endY)) {
sandState[XY(endX, endY)] = 1;
}
if (particlesFallen < TOTAL_PARTICLES) {
startNewFallingParticle();
} else {
animationComplete = true;
}
return;
}
float progress = (float)elapsed / PARTICLE_FALL_TIME;
uint8_t targetX = (fallingParticleX < 8) ? 7 : 8;
if (progress < 0.5) {
float neckProgress = progress * 2;
fallingParticleX = fallingParticleX + (neckProgress * (targetX - fallingParticleX));
fallingParticleY = fallingParticleY + (neckProgress * (7 - fallingParticleY));
} else {
float bottomProgress = (progress - 0.5) * 2;
fallingParticleX = targetX;
uint8_t endY;
uint8_t endX;
findPositionInBottomContainer(&endX, &endY);
fallingParticleY = 8 + (bottomProgress * (endY - 8));
}
}
void playTone(int frequency, int duration) {
tone(BUZZER_PIN, frequency, duration);
}
void playStartSequence() {
for (int i = 0; i < 3; i++) {
playTone(START_TONES[i], START_END_TONE_DURATION);
delay(START_END_TONE_DURATION);
}
startTonesPlayed = true;
}
void playEndSequence() {
for (int i = 0; i < 3; i++) {
playTone(END_TONES[i], START_END_TONE_DURATION);
delay(START_END_TONE_DURATION);
}
endTonesPlayed = true;
}
void setup() {
delay(1000);
pinMode(BUZZER_PIN, OUTPUT);
pinMode(TILT_PIN, INPUT_PULLUP);
FastLED.addLeds<LED_TYPE, LED_PIN, COLOR_ORDER>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
FastLED.setBrightness(BRIGHTNESS);
// Initial orientation check
displayRotated = !digitalRead(TILT_PIN); // Invert because of pull-up
randomSeed(analogRead(0));
initHourglass();
startTime = millis();
startNewFallingParticle();
startTonesPlayed = false; // Reset start tones flag
endTonesPlayed = false; // Reset end tones flag
}
void loop() {
currentTime = millis();
// Check tilt switch with debouncing
if (currentTime - lastTiltCheck >= TILT_CHECK_DELAY) {
bool newRotation = !digitalRead(TILT_PIN); // Invert because of pull-up
if (newRotation != displayRotated) {
displayRotated = newRotation;
// Reset hourglass when flipped
initHourglass();
startTime = currentTime;
particlesFallen = 0;
animationComplete = false;
startNewFallingParticle();
}
lastTiltCheck = currentTime;
}
// Calculate remaining time
int remainingSeconds = 60;
if (currentTime > startTime) {
unsigned long elapsedTime = currentTime - startTime;
if (elapsedTime < RESTART_DELAY) {
remainingSeconds = (RESTART_DELAY - elapsedTime + 999) / 1000;
} else {
remainingSeconds = 0;
}
}
// Play start sequence if not yet played
if (!startTonesPlayed && remainingSeconds == 60) {
playStartSequence();
}
// Play tick tone when second changes
if (remainingSeconds < lastSecond && remainingSeconds > 0) {
playTone(TICK_TONE, TICK_TONE_DURATION);
}
lastSecond = remainingSeconds;
// Play end sequence when countdown reaches zero
if (remainingSeconds == 0 && !endTonesPlayed && animationComplete) {
playEndSequence();
}
// If animation is complete and time is up, show final state
if (animationComplete && (currentTime - startTime >= RESTART_DELAY)) {
// Draw background colors first
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
leds[XY(x, y)] = PALE_RED; // Inside hourglass background
} else if (!isInsideHourglass(x, y)) {
leds[XY(x, y)] = PALE_PURPLE; // Outside hourglass background
}
}
}
// Draw final hourglass state
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA;
}
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW;
}
}
}
// Draw final 00
drawCountdown(0);
FastLED.show();
delay(50);
// return;
}
// Draw background colors first
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isInsideHourglass(x, y) && !isHourglassOutline(x, y)) {
leds[XY(x, y)] = PALE_RED; // Inside hourglass background
} else if (!isInsideHourglass(x, y)) {
leds[XY(x, y)] = PALE_PURPLE; // Outside hourglass background
}
}
}
// Draw the hourglass outline
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (isHourglassOutline(x, y)) {
leds[XY(x, y)] = MAGENTA;
}
}
}
// Draw sand particles
for (uint8_t y = 0; y < GRID_HEIGHT; y++) {
for (uint8_t x = 0; x < GRID_WIDTH; x++) {
if (sandState[XY(x, y)] == 1) {
leds[XY(x, y)] = YELLOW;
}
}
}
// Update and draw falling particle
if (!animationComplete) {
updateFallingParticle();
// Draw falling particle
if (fallingParticle) {
leds[XY(fallingParticleX, fallingParticleY)] = YELLOW;
}
}
// Draw countdown numbers
drawCountdown(remainingSeconds);
FastLED.show();
delay(50); // Slow down animation to 20fps
// return;
}
