[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ブラウザで途切れずに鳴らすための設計を解き明かします。
前回の記事
[CS講座 #09] 走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング // PROTOCOL.LAIN
CRT走査線とV-Blank/H-Blankの仕組み、VRAM構造(パターンテーブル・ネームテーブル)、スプライトの走査線制限と衝突判定、ゲームギアMode 4の画面クロップ(160x144)、JavaScriptによるスキャンライン単位のグラフィック描画コードを徹底解説。
lain-lab.comZ80 EMULATOR PROJECT
Z80 EMULATOR PROJECT
フルスクラッチ JavaScript Z80 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com1. 音は「数値の列」である
スピーカーは、電圧に比例して振動板を押したり引いたりするだけの装置です。デジタルの世界では、この電圧を一定間隔ごとに数値で指定します。これを離散信号と呼びます。
- サンプリング周波数: 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が大きいほど反転が遅くなり、音が低くなります。
周波数の計算式
チップごとに内部の分周比が異なるため、式も少し変わります。
実際の機種で「ラ(A4 = 440Hz)」を出してみます。
| チップ | 機種 | クロック | N | 周波数 |
|---|---|---|---|---|
| SN76489 | Master System | 3,579,545 Hz | 254 | 3,579,545 ÷ (32 × 254) ≒ 440.4 Hz |
| AY-3-8910 | MSX | 1,789,772.5 Hz | 254 | 1,789,772.5 ÷ (16 × 254) ≒ 440.4 Hz |
MSXのPSGはCPUの半分のクロックで動いているため、分周比も半分の16。結果として、同じ N = 254 で同じ音程になります。
逆に、欲しい音程から N を求める式はこうです。
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回で扱ったバッファ残量によるフィードバック制御で吸収します。
まとめ
- 音程 はクロックの整数分周で決まる。 で、SN76489は 、AY-3-8910は 。高音ほど丸め誤差が大きい。
- ノイズ は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全通へのデバッグ格闘記