Skip to content

Instantly share code, notes, and snippets.

@GOROman
Created August 16, 2026 16:57
Show Gist options
  • Select an option

  • Save GOROman/f3d1bff3637fa44e87515eb329e50591 to your computer and use it in GitHub Desktop.

Select an option

Save GOROman/f3d1bff3637fa44e87515eb329e50591 to your computer and use it in GitHub Desktop.
YM3012を使わずにYM2151をステレオで鳴らす — ESP32によるDACシミュレーション手法

YM3012 を使わずに YM2151 をステレオで鳴らす — ESP32 による DAC シミュレーション手法

YM2151 (OPM) の音声出力はアナログではなく、専用 DAC「YM3012」に渡すための シリアルデジタルです。実チップの YM3012 を入手せずに、ESP32-S3 でこのシリアル ストリームを直接デコードしてステレオ PWM 再生する手法をまとめます。

実装は nes-rom-reader の feature/ym2151 ブランチ (ファミコンカセット吸い出し機 + 改造カートリッジに載せた YM2151 を駆動する構成)。

YM2151 → YM3012 のシリアルフォーマット

YM2151 から出る信号は4本:

信号 内容
SO シリアルデータ。1本に L/R が時分割で乗る
SH1 RIGHT ch ワードの区切りパルス
SH2 LEFT ch ワードの区切りパルス
φ1 ビットクロック (= φM/2。φM=4MHz なら 2MHz)
  • サンプルレートは φM/64(4MHz 駆動で 62.5kHz/ch)
  • 1チャンネルスロット = φ1 × 16クロック。先頭3ビットは捨てビット、 続いて 仮数10bit(B0 が LSB、オフセットバイナリ)+ 指数3bit(S0 が LSB) の13bit
  • データは φ1 の立ち下がりで変化するので立ち上がりでサンプルする
  • ワードは SH1/SH2 の立ち下がり時点の直前13bitとして確定する
  • 指数 000 は仕様上の禁止値(これが化けワード検出に使える。後述)

キャプチャ (ESP32-S3)

φ1=2MHz のエッジを1つも落とさず拾うのがキモ。

  1. Dedicated GPIO を使う。通常の GPIO.in レジスタ読みは APB 経由で 約150nsかかり取りこぼすが、Dedicated GPIO バンドルは CPU 命令で 1サイクル読みできる。専用コア(Core 0)のタイトループでポーリング
    • 注意: バンドルは「配列順=ビット順」。GPIO 番号昇順で作らないと ビット対応がズレる
    • 割り込みは止めない(止めると割り込みウォッチドッグでパニックする)。 tick 等で稀に落ちるビットは後段の検証で捨てる
  2. φ1 立ち上がりで SO をシフトイン、SH1/SH2 立ち下がりでワード確定
  3. エッジ数検証: 前回のラッチからの φ1 エッジ数がハーフフレーム分 (16±α)にならないフレームは破棄して直前値を保持
    • 先頭3ビットは捨てビットなので 13〜16 は無条件で採用
    • 17 は余分な1ビットがワード後に入った状態なので、抽出位置を 1ビット補正して採用(補正せずデコードすると化ける)
    • SH2 未配線ならモノラル(SH1→SH1 の 32±α 窓)として動く

デコード

// 13bit ワード → 16bit 相当 PCM
m = (sr >> 19) & 0x3FF;   // 仮数 (シフトレジスタの最新13bitから)
e = (sr >> 29) & 0x07;    // 指数
if (e == 0) reject();     // 禁止値 = 化けワード。実測で毎秒数百個ブロックできた
v   = 2*m - 1023;                  // ±1023 (半LSB中心化)
pcm = (v * 32) >> (7 - e);         // 負数の << は UB なので乗算で

出力 (PWM)

  • LEDC で 78.125kHz / 9bit の PWM を2チャンネル(L/R独立)
  • SH ラッチのたびにデューティ更新 = チップのサンプルレートに完全同期 (リサンプリング不要、ピッチずれゼロ)
  • 1次ノイズシェーピング: 16bit→9bit の量子化誤差を次サンプルへ繰り越し。 クリップ時のワインドアップ防止に誤差を±1量子へクランプ
  • median-3 フィルタ: EMI 等で稀に化ける1サンプルのスパイク (「ぶちぶち」ノイズ)を除去。音楽波形への影響は実質なし
  • 後段に RC ローパス(例: 2.4kΩ + 4.7nF、fc≒14kHz)+ DC カット 1µF → アンプ。 LPF なしだとキャリアとシェーピングノイズが素通しになり逆効果

タイミング上の注意

  • ラッチ処理(デコード+ PWM レジスタ直叩き)は次スロットの捨てビット区間 (φ1×3 ≒ 1.5µs @4MHz)に収める。関数呼び出し(ledcWrite)は遅すぎるので LEDC の LL レジスタを直接叩く
  • 検証窓を「処理遅延で落ちる捨てビット」まで許容しないと、ステレオ化した 途端に採用率が半減して音がガタガタになる(実体験)

ハマりどころ集

  1. LEDC は全タイマーがクロック源を共有 — 別用途(φM 生成)と音声 PWM で ソースが衝突すると片方が黙って壊れる。Arduino の ledcAttachChannel は ch/2 でタイマーが決まる点にも注意
  2. ESP32 の USB-Serial-JTAG は受信バッファが溢れると黙って捨てる — 曲データのストリーミングにはアプリレベルのクレジット式フロー制御が必須
  3. 未使用の /OE 系ピンをフローティングにしない(CMOS 入力が不定になり バスを壊す)。必ず H/L に固定
  4. 電源: FM 8ch フル演奏+アンプでピーク電流が跳ね、esp_reset_reason() が Brownout を返す落ち方をする。バルクコンデンサで解消
  5. 検証だけでは守れない化け方(データビットの化け)は、指数禁止値チェックと median-3 の二段構えで潰す

結果

  • 採用率 ~99%、62.5kHz/9bit ステレオ、X68000 ネイティブピッチ(φM=4MHz)
  • MDX(portable_mdx の再生ロジックで レジスタイベント列に変換)と VGM(レジスタログをそのまま変換)を実チップ再生
  • 34秒の曲を65周(約37分)連続再生できる安定度

デジタル段の S/N は実 YM3012 + アナログ段より素直なくらいで、 「YM3012 が手に入らないから鳴らせない」は過去の話になりました。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment