ESP32-S3 には DAC がない。それでも LEDC(PWM ペリフェラル)+抵抗2本とコンデンサで、 62.5kHz サンプリングのステレオ再生が実用品質でできる。 YM2151 実チップのデジタル音声出力を ESP32 でデコードして鳴らすプロジェクト で実戦投入した手法・工夫・ハマりどころをまとめる。
サンプル値(9bit) → LEDC PWM (78.125kHz キャリア) → RC ローパス → DCカット → アンプ
- キャリア 78.125kHz / 9bit: 80MHz ÷ 2^9 ÷ 2。キャリアが可聴域の遥か上、 かつ後段フィルタで落としやすい
- サンプルごとにデューティを更新するだけ。デューティ=そのサンプルの電圧
#include <driver/ledc.h>
#define PIN_AUDIO 2
#define PWM_FREQ 78125 // 80MHz / 512 / 2
#define PWM_RES 9 // duty 0-511, 中点 256
void audioBegin() {
ledcAttachChannel(PIN_AUDIO, PWM_FREQ, PWM_RES, /*ch=*/0);
ledcWrite(PIN_AUDIO, 256); // 無音 = 中点
}
// サンプルレートのタイマ/割り込み/ループから呼ぶ
void audioWrite(int16_t pcm) { // pcm: -32768..32767
uint32_t duty = ((int32_t)pcm + 32768) >> 7; // 16bit -> 9bit
ledcWrite(PIN_AUDIO, duty);
}freq × 2^res がクロック源を超える(分周比 < 1)と div_param=0 で
静かに失敗する。80MHz源で 78.125kHz なら 9bit が上限
(10bit にしたければキャリアを 39kHz に落とす)。
別用途(高周波クロック生成など)で LEDC_USE_APB_CLK を明示すると、
Arduino の ledcAttach(AUTO=XTAL を選びがち)と timer clock conflict
になり、片方が黙って壊れる。複数用途で使うなら全タイマーの clk_cfg を揃えること。
ledcAttachChannel(pin, f, res, ch) のタイマーは ch/2 で決まる。
ch0/1 = timer0、ch6/7 = timer3。別周波数で使いたい2系統を ch6 と ch7 に
置くと同じタイマーを取り合って周波数がどちらかに化ける。
同周波数のステレオ L/R は ch0/ch1(同一タイマー)がむしろ好都合
(周波数変更が1回で両chに効く)。
タイトなループ(今回は 2MHz 信号のポーリング内)から ledcWrite を呼ぶと
数µs 消えて他の処理を取りこぼす。LL API なら ~100ns:
#include <hal/ledc_ll.h>
static inline void dutyFast(ledc_channel_t ch, uint32_t duty) {
ledc_ll_set_duty_int_part(&LEDC, LEDC_LOW_SPEED_MODE, ch, duty);
ledc_ll_set_duty_start(&LEDC, LEDC_LOW_SPEED_MODE, ch, true);
ledc_ll_ls_channel_update(&LEDC, LEDC_LOW_SPEED_MODE, ch);
}16bit ソースを 9bit に丸める誤差を次サンプルへ繰り越すと、量子化ノイズが 高域へ移動して RC フィルタで削れる。クリップ時に誤差が無限に溜まる 「ワインドアップ」対策のクランプが必須:
static int32_t nsErr = 0;
uint32_t shape(int32_t pcm16) { // pcm16: -32768..32767
int32_t acc = (pcm16 + 32768) + nsErr;
int32_t duty = acc >> 7; // 16bit -> 9bit
if (duty < 0) duty = 0; else if (duty > 511) duty = 511;
nsErr = acc - (duty << 7); // 誤差繰り越し
if (nsErr > 127) nsErr = 127; // アンチワインドアップ
else if (nsErr < -128) nsErr = -128;
return duty;
}注意: シェーピングは後段の LPF が前提。 フィルタなし(カップリング コンデンサ直結)だと高域に追いやったノイズがアンプに素通しで、 むしろ「サー」が増えて聴こえる。
入力データが稀に化ける環境(EMI 等)では、1サンプルの飛び値が 「ぶちぶち」ノイズになる。3タップメディアンで単発スパイクだけ消せる (音楽波形への影響は実質なし):
static int32_t h1 = 256, h2 = 256;
int32_t med3(int32_t a, int32_t b, int32_t c) {
int32_t lo = a < b ? a : b, hi = a < b ? b : a;
return c < lo ? lo : (c > hi ? hi : c); // = median(a,b,c)
}
uint32_t despike(int32_t duty) {
int32_t out = med3(h2, h1, duty);
h2 = h1; h1 = duty; // 履歴は常に更新する
return out;
}v << 6 は v が負だと C/C++ で未定義動作。乗算で書く(v * 64)。
最適化で同じコードになる。
GPIO ──[1kΩ]──┬──[10nF]── GND ← 1次LPF fc≒16kHz
└──[1µF]──→ アンプ入力 ← DCカット必須(PWMはVdd/2中心)
- fc は「R×C ≒ 10µs」あたり。手持ち部品で 2.4kΩ+4.7nF(fc≒14kHz)でも同等
- 直列Rはアンプ入力インピーダンスとの分圧になる。10kΩ だと10k入力の アンプで音量半減、1k前後なら実質無視できる
- DCカットなしで直結しないこと(無音時もデューティ50%の矩形が出ている)
- PWM で音量調整: 起動音などの矩形波トーンは、デューティを 50% から
数%に絞ると音量が下がる(3%で約-20dB)。
ledcWriteToneは 50% 固定なのでledcChangeFrequency+ledcWrite(pin, 小さい値)で - 周波数のリアルタイム変更:
ledc_set_freq()は動作中でも安全に変えられる。 戻り値のエラーチェックを忘れずに(範囲外は失敗して周波数が変わらない) - サンプルレートよりキャリアが速い限り、デューティ更新は「次のPWM周期から」 反映される。62.5kHz 更新 × 78kHz キャリアで実用上問題なし
この構成(9bit + ノイズシェーピング + median + RC 1次)で、YM2151 の FM 音源をステレオ再生して「レトロ実機として十分楽しめる」品質が出ている。 さらに上を目指すなら PCM5102 等の I2S DAC へ(配線3本で16bit化できる)。