[CS講座 #10] 音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる

[CS講座 #10] 音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる

はじめに

前回はVDPが画面を描く仕組みを扱いました。【応用編】の第10回は「音」です。

レトロ機の音源チップは、CPUから見るとI/Oポートの先にあるレジスタの集まりに過ぎません。CPUは数値を書き込むだけで、波形は一切計算しません。では、OUT ($7F), A で書き込んだ 0x8E という数字が、どうやってスピーカーから「ピー」という音になるのか。

今回は、SN76489(SG-1000 / Master System / Game Gear / ColecoVision)と AY-3-8910(MSX / ZX Spectrum 128K / Amstrad CPC)を題材に、その変換の仕組みと、Webブラウザで途切れずに鳴らすための設計を解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. 音は「数値の列」である

スピーカーは、電圧に比例して振動板を押したり引いたりするだけの装置です。デジタルの世界では、この電圧を一定間隔ごとに数値で指定します。これを離散信号と呼びます。

  • サンプリング周波数: 1秒間に何回数値を送るか。ブラウザでは通常 44,100Hz か 48,000Hz。
  • サンプル値: その瞬間の振動板の位置。Web Audio API では -1.0 〜 +1.0 の Float32。

つまり音源エミュレーションの仕事は、「1秒間に48,000個の数値を、1つも欠かさず作り続けること」です。画面は1コマ落ちても気づかれませんが、音は1ミリ秒途切れただけで「プチッ」と聞こえます。


2. 矩形波と分周:音程はどう決まるのか

レトロ音源の基本波形は矩形波(Square Wave)です。値が「高」と「低」を一定間隔で行き来するだけの、最も単純な波形です。

 +1 ┌──┐  ┌──┐  ┌──┐
    │  │  │  │  │  │
 -1 ┘  └──┘  └──┘  └──
    |←→|
    半周期 = N カウント

チップの内部にはカウンタがあり、入力クロックを分周しながらカウントダウンし、0になるたびに出力を反転させます。レジスタに書く値 N は、この「反転までのカウント数」です。Nが大きいほど反転が遅くなり、音が低くなります。

周波数の計算式

チップごとに内部の分周比が異なるため、式も少し変わります。

fSN76489=Clock32×NfAY-3-8910=Clock16×Nf_{\text{SN76489}} = \frac{Clock}{32 \times N} \qquad f_{\text{AY-3-8910}} = \frac{Clock}{16 \times N}

実際の機種で「ラ(A4 = 440Hz)」を出してみます。

チップ機種クロックN周波数
SN76489Master System3,579,545 Hz2543,579,545 ÷ (32 × 254) ≒ 440.4 Hz
AY-3-8910MSX1,789,772.5 Hz2541,789,772.5 ÷ (16 × 254) ≒ 440.4 Hz

MSXのPSGはCPUの半分のクロックで動いているため、分周比も半分の16。結果として、同じ N = 254 で同じ音程になります。

逆に、欲しい音程から N を求める式はこうです。

N=round⁡(Clock32×f)N = \operatorname{round}\left(\frac{Clock}{32 \times f}\right)

round で整数に丸めるため、高い音ほど N が小さくなり、音程の誤差が大きくなります。レトロゲームの高音域が微妙に外れて聞こえるのは、この整数分周の限界です。


3. ノイズ:LFSRで作る「ザー」

爆発音やドラムは、周期性のないノイズで作ります。とはいえ、チップの中に本格的な乱数生成器を載せる余裕はありません。そこで使われるのが LFSR(Linear Feedback Shift Register / 線形帰還シフトレジスタ) です。

 [LFSRの1ステップ]
  1. 特定のビット(タップ)同士を XOR する
  2. レジスタ全体を1ビット右にシフト
  3. XOR結果を最上位ビットに戻す
  4. 最下位ビットがそのまま出力(0 or 1)

たったこれだけで、人間の耳には「ランダム」に聞こえるビット列が得られます。

// SN76489(セガ系): 16ビットLFSR、タップはビット0と3
function stepNoise(lfsr) {
  const feedback = (lfsr & 1) ^ ((lfsr >> 3) & 1);
  return (lfsr >> 1) | (feedback << 15);
}

ビット幅とタップ位置はチップによって違います(TI純正のSN76489は15ビット、AY-3-8910は17ビット)。タップが違えばノイズの「質感」も変わるため、機種ごとの音の個性はここにも表れます。


4. 音量:2dB刻みの対数テーブル

SN76489の音量レジスタは4ビット(0〜15)ですが、これは音量ではなく「減衰量(Attenuation)」です。1段階ごとに2dB小さくなり、15で無音になります。

人間の耳は音の大きさを対数で感じるため、線形の値をそのまま掛けると、小さい音域の段階が粗く聞こえてしまいます。そこで、あらかじめ変換テーブルを用意しておきます。

// 0 = 最大音量、15 = 無音(SN76489)
// dB から振幅への変換: amplitude = 10^(dB / 20)
const VOLUME_TABLE = Array.from({ length: 16 }, (_, i) =>
  i === 15 ? 0 : Math.pow(10, (-2 * i) / 20)
);

AY-3-8910 も4ビットの対数音量ですが、こちらはエンベロープジェネレーターを内蔵しており、CPUが音量を書き換えなくても、ハードウェアだけで「ポーン」と減衰する音を作れます。


5. ミキシングと機種ごとの個性

複数のチャンネルは、単純に足し算で混ぜます(加算合成)。ただし、機種ごとに一癖あります。

  • AY-3-8910: レジスタ7(ミキサー)で、各チャンネルにトーンとノイズのどちらを混ぜるかを選べる。ビットが 0 で有効(負論理)なのが実装時の落とし穴。
  • Game Gear: ポート 0x06 で、各チャンネルを左右どちらのスピーカーに出すかを指定できる。上位4ビットが左、下位4ビットが右。携帯機なのにステレオ。
  • Konami SCC / Namco WSG: 矩形波ではなく、32サンプルの波形メモリを読み出す方式。好きな波形を書き込めるため、音色の自由度が一気に上がる。
  • Apple II: 音源チップすら無く、$C030 を読むたびにスピーカーが1回反転するだけ。音程も音色も、CPUが反転のタイミングですべて作っていた。

6. チップクロックとサンプリング周波数のずれ

ここからがWebブラウザ特有の問題です。

SN76489 の内部カウンタは 3,579,545 ÷ 16 ≒ 223,722 Hz で動いていますが、ブラウザが要求するのは 48,000Hz です。1サンプルあたり約4.66回ぶんチップを進める必要があります。

単純に「4.66回に1回だけ値を拾う」と、高い音でエイリアシング(実在しない濁った音)が発生します。進めた分の出力を平均するだけでも、簡易的なローパスフィルタとして働き、かなり聴きやすくなります。

 チップ出力 (223kHz) : 1 1 1 0 0 | 0 0 1 1 1 | 1 0 0 0 0 ...
                        ↓ 平均        ↓ 平均        ↓ 平均
 ブラウザ出力 (48kHz) :   0.6          0.6          0.2

7. AudioWorklet:波形ではなく「レジスタ書き込み」を送る

第8回で扱ったとおり、音の生成はメインスレッドから切り離し、AudioWorklet(音声専用スレッド)で行います。ここで重要なのは、メインスレッドから何を送るかです。

[メインスレッド]                        [AudioWorkletスレッド]
 CPU が OUT 命令を実行                    128サンプルごとに process() が呼ばれる
   └→ { cycle, value } を          ─→    キューからレジスタ書き込みを取り出し
       port.postMessage で送る             該当サンプル位置で反映しつつ波形を生成
  • 波形そのもの(48,000個/秒)ではなく、レジスタへの書き込み(1フレームに数十回程度)だけを送るため、スレッド間の通信量が圧倒的に少ない。
  • 書き込みにCPUサイクルを添えておけば、Worklet側で「何サンプル目で音量が変わったか」を正確に再現できる。

Game Gear の「セーガー♪」のように、CPUが音量レジスタを高速に書き換えて PCM(録音音声) を再生するソフトでは、このサイクル同期がないと声が潰れます。逆に同期さえ取れていれば、矩形波しか出せないはずのチップから人の声が出ます。

途切れたときの保険

それでもメインスレッドが止まれば、Worklet側のキューは空になります。そのとき出力を突然 0 にすると、波形が崖のように落ちてクリックノイズになります。最後の値を保持するか、ゆっくり0へ近づけるだけで、止まったこと自体はほとんど気づかれなくなります。


8. エミュレーターでの実装(JavaScript)

SN76489 のレジスタ書き込みプロトコルと、AudioWorklet 上での波形生成を組み合わせた実装例です。

SN76489 のレジスタ書き込み(ラッチ/データ方式)

SN76489 はポートが1つしかなく、1バイトの書き込みで「どのレジスタに」「何を」書くかを表現します。

 ラッチバイト: 1 cc t dddd   (cc=チャンネル0-3, t=0:音程/1:音量, dddd=下位4ビット)
 データバイト: 0 - dddddd    (直前にラッチしたレジスタの上位6ビット)
// sn76489-processor.js(AudioWorklet)
const CLOCK = 3579545;
const VOLUME_TABLE = Array.from({ length: 16 }, (_, i) =>
  i === 15 ? 0 : Math.pow(10, (-2 * i) / 20)
);

class SN76489Processor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.tone = [1, 1, 1];          // 10ビット周期 N
    this.vol = [15, 15, 15, 15];    // 減衰量(15 = 無音)
    this.counter = [0, 0, 0, 0];
    this.output = [1, 1, 1, 1];     // 各チャンネルの現在の出力(+1 / -1)
    this.noiseCtrl = 0;
    this.lfsr = 0x8000;
    this.latched = 0;               // 直前にラッチしたレジスタ番号(0-7)

    this.queue = [];                // { cycle, value } の配列
    this.cyclesPerSample = CLOCK / sampleRate;
    this.cycle = 0;                 // Worklet側で進めたCPUサイクル
    this.last = 0;

    this.port.onmessage = (e) => this.queue.push(...e.data);
  }

  write(value) {
    if (value & 0x80) {                           // ラッチバイト
      this.latched = (value >> 4) & 0x07;
      this.applyLow(value & 0x0f);
    } else if (!(this.latched & 1) && this.latched < 6) {
      // データバイト(音程レジスタの上位6ビット)
      const ch = this.latched >> 1;
      this.tone[ch] = (this.tone[ch] & 0x0f) | ((value & 0x3f) << 4);
    } else {
      this.applyLow(value & 0x0f);
    }
  }

  applyLow(d) {
    const ch = this.latched >> 1;
    if (this.latched & 1) this.vol[ch] = d;                 // 音量
    else if (ch < 3) this.tone[ch] = (this.tone[ch] & 0x3f0) | d;
    else { this.noiseCtrl = d; this.lfsr = 0x8000; }        // ノイズ設定でLFSRリセット
  }

  // チップを1ステップ(16クロック)進めて、ミックス後の値を返す
  tick() {
    for (let ch = 0; ch < 3; ch++) {
      if (--this.counter[ch] <= 0) {
        this.counter[ch] = this.tone[ch] || 1;
        this.output[ch] = -this.output[ch];
      }
    }
    if (--this.counter[3] <= 0) {
      const rate = this.noiseCtrl & 3;
      this.counter[3] = rate === 3 ? this.tone[2] * 2 : 0x20 << rate;
      const white = this.noiseCtrl & 4;
      const fb = white ? ((this.lfsr ^ (this.lfsr >> 3)) & 1) : (this.lfsr & 1);
      this.lfsr = (this.lfsr >> 1) | (fb << 15);
      this.output[3] = (this.lfsr & 1) ? 1 : -1;
    }

    let mix = 0;
    for (let ch = 0; ch < 4; ch++) mix += this.output[ch] * VOLUME_TABLE[this.vol[ch]];
    return mix / 4;
  }

  process(_inputs, outputs) {
    const out = outputs[0][0];
    const chipStepsPerSample = this.cyclesPerSample / 16;
    let acc = 0;

    for (let i = 0; i < out.length; i++) {
      this.cycle += this.cyclesPerSample;

      // このサンプル時刻までに届いているレジスタ書き込みを反映
      while (this.queue.length && this.queue[0].cycle <= this.cycle) {
        this.write(this.queue.shift().value);
      }

      // チップを整数回進め、その平均を1サンプルにする(簡易ローパス)
      acc += chipStepsPerSample;
      let sum = 0, n = 0;
      while (acc >= 1) { sum += this.tick(); acc -= 1; n++; }
      this.last = n ? sum / n : this.last;
      out[i] = this.last;
    }
    return true;
  }
}

registerProcessor('sn76489-processor', SN76489Processor);

メインスレッド側(CPUの OUT 命令から送る)

const ctx = new AudioContext();
await ctx.audioWorklet.addModule('/sn76489-processor.js');
const psg = new AudioWorkletNode(ctx, 'sn76489-processor');
psg.connect(ctx.destination);

let pending = [];

// Z80 のバスに接続(SMS/GG では 0x40〜0x7F が PSG)
bus.outPort = (port, value) => {
  if ((port & 0xc0) === 0x40) {
    pending.push({ cycle: cpu.totalCycles, value });
  }
};

// 1フレーム分のCPU実行が終わったら、まとめて送信
function endOfFrame() {
  if (pending.length) {
    psg.port.postMessage(pending);
    pending = [];
  }
}

1フレームぶんの書き込みをまとめて送り、Worklet側はサイクルを見ながら「その時刻になったら」反映します。メインスレッドとWorkletのサイクルのずれは、第8回で扱ったバッファ残量によるフィードバック制御で吸収します。


まとめ

  • 音程 はクロックの整数分周で決まる。f=Clock/(k×N)f = Clock / (k \times N) で、SN76489は k=32k=32、AY-3-8910は k=16k=16。高音ほど丸め誤差が大きい。
  • ノイズ はLFSRで作る疑似乱数。ビット幅とタップ位置が、機種ごとのノイズの質感を決める。
  • 音量 は2dB刻みの減衰量。対数テーブルで振幅に変換する。
  • ミキシング は加算合成だが、負論理ミキサー(AY)、ステレオ(Game Gear)、波形メモリ(SCC/WSG)など機種ごとの個性が出る。
  • ブラウザで鳴らす には、チップクロックから48kHzへの変換(平均化による簡易ローパス)と、AudioWorkletへサイクル付きのレジスタ書き込みを送る設計が鍵になる。

CPUはただ数値を書くだけ。その数値を「時間」に変換するのが音源チップで、チップを「サンプル列」に変換するのがエミュレーターです。前回の画面が「空間をどう切るか」の話だったのに対して、音は「時間をどう切るか」の話でした。

次回は、アーケード基板の世界へ。「第11回:マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)」をお届けします。


シリーズ全目次

  • 【基礎編】
  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?
  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
  • 【実践編】
  • 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み
  • 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む
  • 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり
  • 第8回:クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む
  • 【応用編】
  • 第9回:走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング
  • 第10回:音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる(本記事)
  • 第11回:マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)(次回)
  • 第12回:テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記