Phát âm thanh trên ESP32: PWM, DAC 8 bit và I2S với MAX98357A, tạo sóng sin không cần thư viện
So sánh ba cách phát âm thanh trên ESP32: PWM, DAC 8 bit có DMA và I2S với MAX98357A; tạo sóng sin bằng bảng tra và bộ cộng pha, tránh tiếng tạch, phát giai điệu mẫu.
ESP32 phát được âm thanh theo ba cách, từ tiếng bíp đơn giản tới nhạc, giọng nói rõ ràng: PWM (xung vuông từ khối LEDC), DAC 8 bit có sẵn ở GPIO25, GPIO26, và I2S gửi âm thanh số tới mạch khuếch đại như MAX98357A. Bài này so sánh ba cách, giải thích cách tạo dạng sóng sin bằng bảng tra và bộ cộng pha, cách tránh tiếng "tạch" giữa các nốt, rồi viết một chương trình phát giai điệu theo cả ba cách, không cần thư viện ngoài.
1. Ba cách tạo âm thanh

- PWM:
ledcWriteTone(chân, tần số)tạo xung vuông đúng tần số nốt. Chỉ có hai mức nên tiếng giống còi điện tử, nhưng cực kỳ đơn giản và chân nào cũng dùng được. Hợp với âm báo, giai điệu đơn. - DAC: ESP32 (bản gốc) có hai bộ chuyển đổi số – tương tự 8 bit ở GPIO25 và GPIO26, ra được điện áp 0–3,3 V với 256 mức. Gửi đều đặn các mẫu của dạng sóng là ra âm thanh bất kỳ, nhưng tín hiệu yếu, cần mạch khuếch đại, và 8 bit nghe hơi rè. Các dòng ESP32-S3, C3 không có DAC.
- I2S: gửi mẫu 16 bit ở dạng số qua ba dây tới chip như MAX98357A, chip này vừa chuyển sang tương tự vừa khuếch đại (khoảng 3 W với loa 4 Ω). Âm thanh rõ nhất, ít nhiễu nhất, và có trên mọi dòng ESP32.
2. Tạo sóng sin bằng bảng tra và bộ cộng pha
Tính sin() cho từng mẫu (20 000 lần mỗi giây) thì tốn thời gian. Chương trình tính sẵn một bảng sin 256 điểm, rồi dùng một biến pha 32 bit: mỗi mẫu cộng thêm buoc = f × 232 / 20 000, và lấy 8 bit cao của pha làm chỉ số tra bảng. Biến pha tự quay vòng khi tràn, đúng như góc quay hết một vòng. Với nốt A4 (440 Hz), mỗi chu kỳ dài khoảng 45 mẫu.
Bật tắt nốt đột ngột tạo ra tiếng "tạch" khó chịu, vì sóng nhảy từ một giá trị bất kỳ về 0. Hàm heSoBao() cho âm lượng lên dần trong 5 ms đầu và xuống dần trong 5 ms cuối mỗi nốt, tiếng nghe mượt hẳn.
Với DAC, mẫu phải không âm: 128 là im lặng, sóng dao động quanh 128. Với I2S, mẫu là số 16 bit có dấu, mỗi khung gồm kênh trái và phải; chương trình ghi cùng một giá trị cho cả hai kênh.
3. Nối dây


| Cách | Chân ESP32 | Nối tới |
|---|---|---|
| 1. PWM | GPIO26 | Điện trở 1 kΩ vào base transistor NPN; còi hoặc loa nhỏ (nối tiếp điện trở 47–100 Ω) từ 5 V xuống collector |
| 2. DAC | GPIO25 | Tụ 10 µF nối tiếp vào ngõ vào mạch khuếch đại (ví dụ PAM8403), GND chung |
| 3. I2S | GPIO27 (BCLK), GPIO14 (LRC), GPIO22 (DIN) | MAX98357A: VIN 5 V, GND; loa vào hai cọc + và − |
Không nối loa thẳng vào chân ESP32: loa 8 Ω cần dòng lớn hơn rất nhiều so với mức một chân GPIO chịu được. Với MAX98357A, hai cọc loa đều là ngõ ra (mạch cầu), không cọc nào nối GND. Chân GAIN để trống cho độ khuếch đại 9 dB; nối GND hoặc VIN để đổi mức.
4. Chương trình
Đặt hằng CACH_PHAT = 1, 2 hoặc 3 rồi nạp; gõ P trên Serial Monitor để phát giai điệu dân gian Frère Jacques (ở Việt Nam quen với lời "Kìa con bướm vàng"). Mỗi lần chỉ khởi động một cách, để DAC (dùng chung khối DMA với I2S trên ESP32) và I2S không giành nhau phần cứng.
// PYWORLD.VN - Phát âm thanh trên ESP32 theo 3 cách: xung PWM (LEDC), DAC 8 bit có DMA (GPIO25), I2S ra mạch khuếch đại MAX98357A
// Chọn cách bằng hằng CACH_PHAT rồi nạp lại; gõ P trên Serial Monitor để phát bài "Kìa con bướm vàng" (giai điệu dân gian Frère Jacques)
// Không cần thư viện ngoài: LEDC, driver DAC của ESP-IDF và thư viện ESP_I2S đều có trong gói esp32 3.x
#include <ESP_I2S.h>
#include <driver/dac_continuous.h>
const int CACH_PHAT = 3; // 1: PWM ở GPIO26, 2: DAC ở GPIO25, 3: I2S ra MAX98357A
const int CHAN_PWM = 26;
const int CHAN_BCLK = 27, CHAN_LRC = 14, CHAN_DIN = 22; // nối với BCLK, LRC, DIN của MAX98357A
const uint32_t TAN_SO_MAU = 20000; // 20 000 mẫu mỗi giây (DAC của ESP32 cần từ khoảng 19,6 kHz trở lên)
const int BIEN_DO_I2S = 8000; // trên 32 767: khoảng 1/4 mức lớn nhất, đủ to với loa 3 W
const int BIEN_DO_DAC = 90; // trên 127 (DAC 8 bit, giữa là 128)
struct Not { uint16_t tanSo, ms; }; // tần số 0 = nghỉ
const Not BAI_HAT[] = {
{262, 400}, {294, 400}, {330, 400}, {262, 400}, {262, 400}, {294, 400}, {330, 400}, {262, 400},
{330, 400}, {349, 400}, {392, 800}, {330, 400}, {349, 400}, {392, 800},
{392, 200}, {440, 200}, {392, 200}, {349, 200}, {330, 400}, {262, 400},
{392, 200}, {440, 200}, {392, 200}, {349, 200}, {330, 400}, {262, 400},
{262, 400}, {196, 400}, {262, 800}, {262, 400}, {196, 400}, {262, 800},
};
I2SClass i2s;
dac_continuous_handle_t dac;
// Bảng sin 256 điểm, tính một lần; mẫu thứ n của nốt tần số f là bangSin[pha >> 24] với pha tăng f * 2^32 / TAN_SO_MAU mỗi mẫu.
int16_t bangSin[256];
void taoBangSin() {
for (int i = 0; i < 256; i++) bangSin[i] = (int16_t)(32767 * sin(2 * PI * i / 256));
}
// Hệ số âm lượng 0..256 theo vị trí trong nốt: lên dần 5 ms đầu, xuống dần 5 ms cuối để không nghe tiếng "tạch".
int heSoBao(uint32_t n, uint32_t tong) {
const uint32_t len = TAN_SO_MAU / 200;
if (n < len) return n * 256 / len;
if (tong - n < len) return (tong - n) * 256 / len;
return 256;
}
void phatNotI2S(uint16_t f, uint32_t soMau) {
static int16_t dem[512]; // 256 khung, mỗi khung 2 kênh (trái, phải giống nhau)
uint32_t pha = 0, buoc = (uint32_t)((uint64_t)f * 4294967296ULL / TAN_SO_MAU);
for (uint32_t n = 0; n < soMau;) {
int k = 0;
for (; k < 256 && n < soMau; k++, n++) {
int32_t s = f ? (int32_t)bangSin[pha >> 24] * BIEN_DO_I2S / 32767 * heSoBao(n, soMau) / 256 : 0;
dem[2 * k] = dem[2 * k + 1] = s;
pha += buoc;
}
i2s.write((uint8_t *)dem, k * 4); // chờ khi bộ đệm DMA đầy, nên nhịp phát do phần cứng giữ
}
}
void phatNotDAC(uint16_t f, uint32_t soMau) {
static uint8_t dem[512];
uint32_t pha = 0, buoc = (uint32_t)((uint64_t)f * 4294967296ULL / TAN_SO_MAU);
for (uint32_t n = 0; n < soMau;) {
int k = 0;
for (; k < 512 && n < soMau; k++, n++) {
int s = f ? (int32_t)bangSin[pha >> 24] * BIEN_DO_DAC / 32767 * heSoBao(n, soMau) / 256 : 0;
dem[k] = 128 + s; // DAC không có số âm: 0..255, im lặng là 128
pha += buoc;
}
dac_continuous_write(dac, dem, k, NULL, -1);
}
}
void phatBai() {
for (const Not &nt : BAI_HAT) {
uint32_t soMau = TAN_SO_MAU * nt.ms / 1000;
if (CACH_PHAT == 1) {
ledcWriteTone(CHAN_PWM, nt.tanSo); // xung vuông đúng tần số nốt
delay(nt.ms * 9 / 10);
ledcWriteTone(CHAN_PWM, 0); // ngắt một chút giữa hai nốt
delay(nt.ms / 10);
} else if (CACH_PHAT == 2) {
phatNotDAC(nt.tanSo, soMau);
} else {
phatNotI2S(nt.tanSo, soMau);
}
}
if (CACH_PHAT == 3) phatNotI2S(0, TAN_SO_MAU / 5); // đẩy 0,2 s im lặng ra để nốt cuối phát hết
if (CACH_PHAT == 2) phatNotDAC(0, TAN_SO_MAU / 5);
Serial.println("Phat xong");
}
void setup() {
Serial.begin(115200);
taoBangSin();
if (CACH_PHAT == 1) {
ledcAttach(CHAN_PWM, 1000, 10);
ledcWriteTone(CHAN_PWM, 0);
} else if (CACH_PHAT == 2) {
dac_continuous_config_t cauHinh = {
.chan_mask = DAC_CHANNEL_MASK_CH0, // GPIO25
.desc_num = 4,
.buf_size = 1024,
.freq_hz = TAN_SO_MAU,
.offset = 0,
.clk_src = DAC_DIGI_CLK_SRC_DEFAULT,
.chan_mode = DAC_CHANNEL_MODE_SIMUL,
};
if (dac_continuous_new_channels(&cauHinh, &dac) != ESP_OK || dac_continuous_enable(dac) != ESP_OK) {
Serial.println("Khong khoi dong duoc DAC");
}
} else {
i2s.setPins(CHAN_BCLK, CHAN_LRC, CHAN_DIN);
if (!i2s.begin(I2S_MODE_STD, TAN_SO_MAU, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO)) {
Serial.println("Khong khoi dong duoc I2S");
}
}
const char *ten[] = {"", "PWM GPIO26", "DAC GPIO25", "I2S MAX98357A"};
Serial.printf("Cach phat: %s, %lu mau/giay. Go P de phat\n", ten[CACH_PHAT], (unsigned long)TAN_SO_MAU);
}
void loop() {
if (Serial.available() && toupper(Serial.read()) == 'P') {
Serial.println("Dang phat...");
phatBai();
}
}
- Cách 2 dùng trình điều khiển
dac_continuouscủa ESP-IDF (có sẵn trong gói esp32 3.x): mẫu được DMA đẩy ra đúng 20 000 lần mỗi giây, chương trình chỉ cần ghi đầy bộ đệm. DAC của ESP32 cần tần số lấy mẫu từ khoảng 19,6 kHz trở lên với nguồn xung mặc định, nên bài dùng 20 kHz. - Cách 3 dùng thư viện
ESP_I2S:i2s.write()chờ khi bộ đệm DMA đầy, nên nhịp phát do phần cứng giữ, chương trình không phải tự canh giờ. - Cuối bài phát thêm 0,2 giây im lặng để đẩy hết mẫu còn trong bộ đệm ra loa.
5. Kết quả mong đợi

BAI_HAT: mỗi ô là một nốt (tần số, thời gian); giai điệu dân gian Frère Jacques.
Nghe lần lượt ba cách sẽ thấy rõ khác biệt: PWM vang, chói như còi; DAC tròn tiếng hơn nhưng có tiếng xì nền; I2S với MAX98357A rõ và to nhất. Muốn phát file âm thanh, mỗi mẫu trong file WAV chính là một số 16 bit để gửi qua I2S như trong hàm phatNotI2S(); file nhỏ lưu được trong LittleFS, file lớn nên để trên thẻ nhớ.
6. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| MAX98357A không kêu | Nối nhầm BCLK và LRC; thiếu nguồn VIN; loa hỏng | Đối chiếu bảng nối dây; đo 5 V ở VIN |
| Tiếng rè, méo khi to | Biên độ quá lớn, nguồn 5 V yếu | Giảm BIEN_DO_I2S; nguồn 5 V đủ dòng (tới 1 A khi to) |
| Tiếng "tạch" giữa các nốt | Bỏ phần lên dần, xuống dần | Giữ heSoBao() |
| Nhạc phát nhanh hoặc chậm hơn | Tần số lấy mẫu khai báo khác tần số thật | Dùng cùng TAN_SO_MAU khi tạo mẫu và khi khởi động I2S, DAC |
| "Khong khoi dong duoc DAC" | Tần số lấy mẫu dưới mức DAC hỗ trợ; dùng chip không có DAC | Giữ 20 kHz trở lên; ESP32-S3, C3 dùng I2S |
| Loa PWM kêu nhỏ, transistor nóng | Loa nối thẳng không có điện trở hạn dòng | Thêm điện trở 47–100 Ω nối tiếp loa |
7. Xem thêm
- PWM trên ESP32: khối LEDC dùng cho cách 1.
- Đàn piano trái cây với ESP32: dự án STEM phát nốt nhạc bằng còi.
- Bài micro I2S INMP441 trong loạt module phổ biến: chiều ngược lại, thu âm thanh qua I2S.
- Bài LittleFS trong cùng loạt ESP32 nâng cao: lưu file âm thanh nhỏ trong flash.
Xem thêm