Đèn LED nháy theo nhạc với ESP32: micro INMP441, FFT tự viết, dải WS2812B, bắt nhịp trống
Làm đèn LED nháy theo nhạc với ESP32: micro INMP441 thu tiếng, FFT 512 điểm chia 6 dải tần, bắt nhịp trống tính BPM, dải WS2812B với ba chế độ phổ, nhịp và VU.
Đèn LED nháy theo nhạc là dự án vui mắt mà lại học được nhiều thứ: thu âm bằng micro số, phân tích tần số bằng FFT, bắt nhịp trống và điều khiển dải LED địa chỉ. Bài này dùng micro INMP441 (I2S), dải 30 LED WS2812B và ESP32, với ba chế độ đổi bằng nút BOOT: phổ 6 dải, nháy theo nhịp trống và VU từ giữa ra. FFT viết ngay trong chương trình, WS2812B điều khiển bằng bộ RMT của ESP32, không cần thư viện ngoài.
1. Đèn nghe nhạc như thế nào
- Thu âm: INMP441 gửi mẫu âm thanh 24 bit qua I2S, 16.000 mẫu mỗi giây. Chương trình lấy từng khung 512 mẫu (32 ms).
- FFT (biến đổi Fourier nhanh) tách khung âm thanh thành 256 ô tần số, mỗi ô rộng 31,25 Hz, cho biết tần số nào mạnh, tần số nào yếu.
- Gom thành 6 dải: trầm (trống, bass), trung, cao (hi-hat, tiếng xì). Mỗi dải được chuẩn hóa về 0–100 % so với mức nền của chính nó, nên không cần chỉnh âm lượng.
- Bắt nhịp: khi năng lượng dải trầm tăng vọt so với trung bình gần đây thì coi là một tiếng trống.
- Vẽ lên LED theo chế độ đang chọn, khoảng 30 lần mỗi giây.
2. Chuẩn bị
- ESP32 DevKit V1 và dây USB
- Micro I2S INMP441
- Dải LED WS2812B 30 bóng (5 V), điện trở 330 Ω, tụ hóa 1000 µF
- Nguồn 5 V từ 2 A cho dải LED
- Breadboard và dây cắm
3. Nối dây

- Chân micro giống bài INMP441, chân dải LED giống bài WS2812B trong cùng loạt mô-đun, nên có thể thử từng phần bằng chương trình của hai bài đó trước.
- Dải 30 LED sáng trắng hết cỡ có thể kéo khoảng 1,8 A, quá sức cổng USB của máy tính: cấp 5 V riêng cho dải, nối GND chung với ESP32, đặt tụ 1000 µF ở đầu dải. Chương trình tự giảm độ sáng khi dòng ước tính vượt 1,5 A.
- Đặt micro cách loa vài chục cm, tránh đặt sát loa trầm làm mức âm luôn đầy.
4. FFT và 6 dải tần

- Cửa sổ Hann: trước khi FFT, mỗi mẫu được nhân với một đường cong hình chuông để hai đầu khung về 0. Không có bước này, chỗ khung bị cắt ngang làm phổ bị "nhòe" sang các ô bên cạnh.
- Dải rộng gấp đôi nhau (60–150, 150–300, 300–600 Hz…): tai người nghe cao độ theo tỉ lệ, mỗi lần gấp đôi tần số là lên một quãng tám. Chia đều theo Hz thì gần hết LED sẽ dành cho tiếng xì ở dải cao.
- Chuẩn hóa tự động (
chuanHoa()): mỗi dải có mức nền đi xuống nhanh khi yên tĩnh, đi lên rất chậm khi có tiếng, và một mức đỉnh giảm dần. Mức hiển thị là vị trí của dải giữa nền và đỉnh. Nhờ vậy nhạc nhỏ hay to đèn vẫn nhảy đủ.
5. Bắt nhịp trống

batNhip() giữ trung bình trượt của năng lượng dải trầm. Một khung có năng lượng trầm lớn hơn 1,6 lần trung bình, cách nhịp trước ít nhất 250 ms, được tính là một nhịp. Khoảng cách giữa 8 nhịp gần nhất cho ra số nhịp mỗi phút (BPM). Cách này hợp với nhạc có trống rõ (pop, EDM, nhạc sàn); nhạc không lời, nhạc cổ điển ít trống thì chế độ nhịp sẽ ít nháy.
6. Chương trình
// PYWORLD.VN - Đèn LED nháy theo nhạc: micro INMP441 thu tiếng, ESP32 tự tính FFT 512 điểm, dải 30 LED WS2812B đổi theo phổ và nhịp
// PYWORLD.VN - INMP441: SCK GPIO26, WS GPIO25, SD GPIO33, L/R xuống GND; WS2812B: DIN GPIO13 qua 330 Ω, nguồn 5 V riêng, GND chung
// PYWORLD.VN - Nút BOOT đổi chế độ: phổ 6 dải, nháy theo nhịp trống, VU từ giữa ra; chỉ dùng thư viện có sẵn trong gói esp32
#include <ESP_I2S.h>
const int CHAN_SCK = 26, CHAN_WS = 25, CHAN_SD = 33;
const int CHAN_DIN = 13;
const int CHAN_NUT = 0;
const int SO_LED = 30;
const uint32_t DONG_NGUON_MA = 1500;
const uint8_t DO_SANG_TOI_DA = 110;
const uint32_t TAN_SO_MAU = 16000;
const int N = 512; // 32 ms mỗi khung, mỗi ô tần số rộng 31,25 Hz
const int SO_DAI = 6;
const float BIEN_DAI[SO_DAI + 1] = {60, 150, 300, 600, 1200, 2400, 4800}; // Hz
const char *TEN_CHE_DO[3] = {"pho 6 dai", "nhay theo nhip", "VU tu giua"};
I2SClass i2s;
int32_t dem[N * 2];
float thuc[N], ao[N], cuaSo[N];
float nen[SO_DAI], dinh[SO_DAI], hien[SO_DAI];
float trungBinhBass = 0, sangNhip = 0;
uint32_t lanNhip = 0, khoangNhip[8];
int soKhoang = 0, viTriKhoang = 0, cheDo = 0, kenh = 0, mauNhip = 0;
uint8_t mau[SO_LED][3];
rmt_data_t kyHieu[SO_LED * 24];
const rmt_data_t BIT_0 = {{4, 1, 8, 0}};
const rmt_data_t BIT_1 = {{8, 1, 4, 0}};
// ---- WS2812B qua RMT, có giới hạn dòng (như bài WS2812B) ----
uint32_t dongUocTinh(uint8_t doSang) {
uint32_t tong = 0;
for (int i = 0; i < SO_LED; i++) tong += mau[i][0] + mau[i][1] + mau[i][2];
return tong * doSang / 255 * 20 / 255 + SO_LED;
}
void hienThi() {
uint8_t doSang = DO_SANG_TOI_DA;
uint32_t dong = dongUocTinh(doSang);
if (dong > DONG_NGUON_MA) doSang = (uint32_t)doSang * (DONG_NGUON_MA - SO_LED) / (dong - SO_LED);
int k = 0;
for (int i = 0; i < SO_LED; i++) {
uint32_t grb = ((uint32_t)(mau[i][1] * doSang / 255) << 16) | ((uint32_t)(mau[i][0] * doSang / 255) << 8) | (mau[i][2] * doSang / 255);
for (int bit = 23; bit >= 0; bit--) kyHieu[k++] = (grb >> bit) & 1 ? BIT_1 : BIT_0;
}
rmtWrite(CHAN_DIN, kyHieu, SO_LED * 24, RMT_WAIT_FOR_EVER);
}
void datMau(int i, uint8_t r, uint8_t g, uint8_t b) {
mau[i][0] = r;
mau[i][1] = g;
mau[i][2] = b;
}
void banhXeMau(int i, uint8_t v, float sang) {
uint8_t r, g, b;
if (v < 85) { r = 255 - v * 3; g = v * 3; b = 0; }
else if (v < 170) { r = 0; g = 255 - (v - 85) * 3; b = (v - 85) * 3; }
else { r = (v - 170) * 3; g = 0; b = 255 - (v - 170) * 3; }
datMau(i, r * sang, g * sang, b * sang);
}
// ---- FFT cơ số 2, tính tại chỗ ----
void fft(float *re, float *im, int n) {
for (int i = 1, j = 0; i < n; i++) { // sắp lại theo thứ tự đảo bit
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
float t = re[i];
re[i] = re[j];
re[j] = t;
t = im[i];
im[i] = im[j];
im[j] = t;
}
}
for (int len = 2; len <= n; len <<= 1) { // ghép các phép biến đổi nhỏ thành lớn (cánh bướm)
float wr = cosf(-2 * PI / len), wi = sinf(-2 * PI / len);
for (int i = 0; i < n; i += len) {
float cr = 1, ci = 0;
for (int k = 0; k < len / 2; k++) {
int a = i + k, b = a + len / 2;
float tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
re[b] = re[a] - tr;
im[b] = im[a] - ti;
re[a] += tr;
im[a] += ti;
float t = cr * wr - ci * wi;
ci = cr * wi + ci * wr;
cr = t;
}
}
}
}
// ---- Đọc micro, tính năng lượng từng dải ----
bool docKhung(float muc[SO_DAI], float &bass) {
size_t n = i2s.readBytes((char *)dem, sizeof(dem));
if (n < sizeof(dem)) return false;
float tb = 0;
for (int i = 0; i < N; i++) {
thuc[i] = (dem[2 * i + kenh] >> 8) / 8388608.0f; // 24 bit có dấu, đổi về -1..1
tb += thuc[i];
}
tb /= N;
for (int i = 0; i < N; i++) {
thuc[i] = (thuc[i] - tb) * cuaSo[i]; // bỏ phần một chiều, nhân cửa sổ Hann
ao[i] = 0;
}
fft(thuc, ao, N);
for (int d = 0; d < SO_DAI; d++) {
int o1 = BIEN_DAI[d] * N / TAN_SO_MAU, o2 = BIEN_DAI[d + 1] * N / TAN_SO_MAU;
double tong = 0;
for (int o = o1; o < o2; o++) tong += thuc[o] * thuc[o] + ao[o] * ao[o];
muc[d] = 10 * log10f(tong + 1e-12f); // dB tương đối
if (d == 0) bass = tong;
}
return true;
}
// Mức nền theo dõi chậm, đỉnh giảm dần: đổi dB của mỗi dải ra 0..1 mà không cần chỉnh âm lượng.
void chuanHoa(const float muc[SO_DAI], float ra[SO_DAI]) {
for (int d = 0; d < SO_DAI; d++) {
nen[d] += (muc[d] - nen[d]) * (muc[d] < nen[d] ? 0.1f : 0.002f);
dinh[d] = max(muc[d], dinh[d] - 0.15f);
float duoi = nen[d] + 6, khoang = max(12.0f, dinh[d] - duoi);
ra[d] = constrain((muc[d] - duoi) / khoang, 0.0f, 1.0f);
}
}
// Nhịp trống: năng lượng dải trầm vượt 1,6 lần mức trung bình gần đây, cách nhịp trước ít nhất 250 ms.
bool batNhip(float bass, uint32_t bay) {
bool co = trungBinhBass > 0 && bass > 1.6f * trungBinhBass && bay - lanNhip > 250;
trungBinhBass += (bass - trungBinhBass) * 0.05f;
if (!co) return false;
if (lanNhip && bay - lanNhip < 2000) {
khoangNhip[viTriKhoang] = bay - lanNhip;
viTriKhoang = (viTriKhoang + 1) % 8;
if (soKhoang < 8) soKhoang++;
}
lanNhip = bay;
return true;
}
int nhipMoiPhut() {
if (soKhoang < 4) return 0;
uint32_t tong = 0;
for (int i = 0; i < soKhoang; i++) tong += khoangNhip[i];
return 60000 * soKhoang / tong;
}
void veLed(const float lv[SO_DAI], bool nhip) {
if (cheDo == 0) { // phổ: 6 cột, mỗi cột 5 LED
for (int d = 0; d < SO_DAI; d++) {
hien[d] = max(lv[d], hien[d] * 0.85f); // lên ngay, xuống từ từ
int cao = lroundf(hien[d] * 5);
for (int k = 0; k < 5; k++) {
if (k < cao) banhXeMau(d * 5 + k, d * 40, 1.0f);
else datMau(d * 5 + k, 0, 0, 0);
}
}
} else if (cheDo == 1) { // nhịp: cả dải sáng lên rồi tắt dần, mỗi nhịp đổi màu
if (nhip) {
sangNhip = 1;
mauNhip = (mauNhip + 37) % 256;
}
for (int i = 0; i < SO_LED; i++) banhXeMau(i, mauNhip, sangNhip);
sangNhip *= 0.8f;
} else { // VU: tổng mức sáng từ giữa ra hai đầu, xanh lá tới đỏ
float tong = 0;
for (int d = 0; d < SO_DAI; d++) tong += lv[d];
hien[0] = max(tong / SO_DAI, hien[0] * 0.9f);
int nua = lroundf(hien[0] * SO_LED / 2);
for (int k = 0; k < SO_LED / 2; k++) {
uint8_t v = 85 - k * 85 / (SO_LED / 2); // giữa xanh lá, ra ngoài chuyển dần sang đỏ
if (k < nua) {
banhXeMau(SO_LED / 2 + k, v, 1.0f);
banhXeMau(SO_LED / 2 - 1 - k, v, 1.0f);
} else {
datMau(SO_LED / 2 + k, 0, 0, 0);
datMau(SO_LED / 2 - 1 - k, 0, 0, 0);
}
}
}
hienThi();
}
void kiemTraNut() {
static bool truoc = HIGH;
static uint32_t lucBam = 0;
bool doc = digitalRead(CHAN_NUT);
if (doc == LOW && truoc == HIGH && millis() - lucBam > 300) {
lucBam = millis();
cheDo = (cheDo + 1) % 3;
for (int d = 0; d < SO_DAI; d++) hien[d] = 0;
Serial.printf("Che do %d: %s\n", cheDo, TEN_CHE_DO[cheDo]);
}
truoc = doc;
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_NUT, INPUT_PULLUP);
for (int i = 0; i < N; i++) cuaSo[i] = 0.5f - 0.5f * cosf(2 * PI * i / (N - 1));
for (int d = 0; d < SO_DAI; d++) {
nen[d] = -60;
dinh[d] = -30;
}
if (!rmtInit(CHAN_DIN, RMT_TX_MODE, RMT_MEM_NUM_BLOCKS_1, 10000000)) Serial.println("Loi khoi dong RMT");
i2s.setPins(CHAN_SCK, CHAN_WS, -1, CHAN_SD);
if (!i2s.begin(I2S_MODE_STD, TAN_SO_MAU, I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO)) {
Serial.println("Khong khoi dong duoc I2S");
while (true) delay(1000);
}
Serial.printf("Den LED theo nhac: %d LED, che do %s. Bam BOOT de doi che do\n", SO_LED, TEN_CHE_DO[cheDo]);
}
void loop() {
kiemTraNut();
float muc[SO_DAI], lv[SO_DAI], bass;
if (!docKhung(muc, bass)) return;
chuanHoa(muc, lv);
bool nhip = batNhip(bass, millis());
veLed(lv, nhip);
static uint32_t lanIn = 0;
if (millis() - lanIn >= 2000) {
lanIn = millis();
Serial.print("Muc 6 dai (%):");
for (int d = 0; d < SO_DAI; d++) Serial.printf(" %3.0f", lv[d] * 100);
int bpm = nhipMoiPhut();
if (bpm) Serial.printf(" | nhip %d BPM\n", bpm);
else Serial.println(" | chua bat duoc nhip");
}
}
Giải thích
fft()là FFT cơ số 2 kinh điển: sắp lại mẫu theo thứ tự đảo bit, rồi ghép dần các phép biến đổi 2, 4, 8… 512 điểm bằng phép "cánh bướm". Với 512 điểm, ESP32 tính xong trong khoảng vài mili giây, còn rất nhiều thời gian trong mỗi khung 32 ms.docKhung()đọc 512 mẫu, bỏ phần một chiều, nhân cửa sổ, FFT, rồi cộng bình phương biên độ các ô trong từng dải và đổi ra dB.veLed(): chế độ phổ chia 30 LED thành 6 cột × 5 LED, cột lên ngay khi có tiếng và xuống từ từ (nhân 0,85 mỗi khung); chế độ nhịp làm cả dải sáng một màu rồi tắt dần, mỗi nhịp đổi màu; chế độ VU sáng từ giữa ra hai đầu theo tổng mức của 6 dải.hienThi()giữ nguyên cách của bài WS2812B: mỗi bit một ký hiệu RMT, tự hạ độ sáng chung nếu dòng ước tính vượtDONG_NGUON_MA.
7. Kết quả


Các hình trong bài tính từ một đoạn nhạc tổng hợp bằng máy tính (trống trầm 120 nhịp mỗi phút, hi-hat, giai điệu) để kết quả lặp lại được; với nhạc thật, phổ và nhịp sẽ thay đổi theo bài hát và độ vang của phòng.
Lưu ý: ánh sáng chớp nhanh, mạnh có thể gây khó chịu, và với người có bệnh động kinh nhạy cảm ánh sáng thì có thể nguy hiểm. Giữ độ sáng vừa phải (DO_SANG_TOI_DA), không để chế độ nhịp nháy chớp liên tục trong phòng tối.
8. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| LED không sáng hoặc màu loạn | Thiếu GND chung, dây DIN dài, thiếu điện trở 330 Ω | Nối GND chung; dây DIN ngắn; với dải dài thêm mạch chuyển mức 3,3 V lên 5 V |
| Đèn đứng yên dù đang mở nhạc | Sai dây I2S, chân L/R để trống | Kiểm tra SCK D26, WS D25, SD D33; L/R nối GND |
| Mọi cột luôn đầy | Micro sát loa, nhạc quá to | Đặt micro xa loa hơn; chờ vài giây cho mức đỉnh tự thích nghi |
| Chế độ nhịp nháy loạn | Nhạc nhiều tiếng trầm liên tục (bass kéo dài) | Tăng hệ số 1,6 trong batNhip() lên 2,0 |
| ESP32 khởi động lại khi LED sáng trắng | Nguồn 5 V yếu, dải LED lấy điện qua ESP32 | Nguồn riêng đủ dòng cho dải; giảm DO_SANG_TOI_DA |
9. Hướng mở rộng
- Lấy tín hiệu thẳng từ cổng tai nghe vào chân ADC (qua tụ và cầu phân áp đưa về giữa 0–3,3 V) thay cho micro, để đèn không bắt tiếng ồn trong phòng.
- Dải dài hơn: tăng
SO_LED, chia lại số LED mỗi cột, nhớ tính lại nguồn. - Đồng bộ nhiều dải đèn bằng ESP-NOW: một ESP32 nghe nhạc, gửi mức 6 dải cho các ESP32 khác.
10. Xem thêm
- PWM trên ESP32: điều chỉnh độ sáng LED bằng LEDC
- Bài dải LED WS2812B với bộ RMT và bài micro I2S INMP441 trong loạt mô-đun: giải thích chi tiết hai phần đầu vào, đầu ra của chương trình này.
Xem thêm