PocketTuneの開発で取り組んだ、Game Boy(DMG-01)互換サウンドエンジンの実装について解説します。チップチューン音楽の心臓部とも言えるこの仕組み、意外と奥が深いんです。
Game Boyのサウンドチップ(APU)とは
Game Boyに搭載されているAPU(Audio Processing Unit)は、4つの独立したサウンドチャンネルを持っています。この制約の中で、あの独特の8bitサウンドが生まれます。
| チャンネル | 種類 | 特徴 |
|---|---|---|
| CH1 | パルス波 | スイープ機能付き(ピッチベンド的な効果) |
| CH2 | パルス波 | シンプルなパルス波 |
| CH3 | 波形メモリ | 32サンプルのカスタム波形 |
| CH4 | ノイズ | パーカッション・効果音向け |
チャンネル1・2:パルス波の実装
パルス波はGame Boyサウンドの主役です。メロディやベースラインに使われます。
デューティ比
パルス波の「音色」を決めるのがデューティ比です。Game Boyでは4種類から選べます。
enum class DutyCycle(val pattern: FloatArray) {
DUTY_12_5(floatArrayOf(-1f, -1f, -1f, -1f, -1f, -1f, -1f, 1f)), // 12.5%
DUTY_25(floatArrayOf(-1f, -1f, -1f, -1f, -1f, -1f, 1f, 1f)), // 25%
DUTY_50(floatArrayOf(-1f, -1f, -1f, -1f, 1f, 1f, 1f, 1f)), // 50%
DUTY_75(floatArrayOf(1f, 1f, 1f, 1f, 1f, 1f, -1f, -1f)) // 75%
}
12.5%は細く鋭い音、50%は丸みのある音になります。この違いだけでも、かなり表現の幅が広がります。
パルス波の生成
fun generatePulse(
frequency: Float,
dutyCycle: DutyCycle,
sampleRate: Int
): FloatArray {
val samplesPerCycle = sampleRate / frequency
val buffer = FloatArray(sampleRate)
for (i in buffer.indices) {
val position = (i % samplesPerCycle) / samplesPerCycle
val patternIndex = (position * 8).toInt() % 8
buffer[i] = dutyCycle.pattern[patternIndex]
}
return buffer
}
CH1のスイープ機能
チャンネル1だけが持つ特殊機能がスイープです。音程を時間とともに上下させることができます。
data class Sweep(
val time: Int, // スイープ間隔(0-7)
val direction: Int, // 0=上昇, 1=下降
val shift: Int // シフト量(0-7)
)
fun applySweep(frequency: Float, sweep: Sweep, step: Int): Float {
if (sweep.time == 0) return frequency
val delta = frequency / (1 shl sweep.shift)
return if (sweep.direction == 0) {
frequency + delta
} else {
frequency - delta
}
}
この機能で、レーザー音やピッチベンドのような効果を作れます。
チャンネル3:波形メモリ
CH3は32サンプル(各4bit)のカスタム波形を再生できます。これが実は一番自由度が高いチャンネルです。
class WaveChannel {
// 32サンプル × 4bit(0-15)
private val waveTable = IntArray(32)
fun setWaveform(data: IntArray) {
require(data.size == 32)
data.forEachIndexed { i, value ->
waveTable[i] = value.coerceIn(0, 15)
}
}
fun getSample(position: Float): Float {
val index = (position * 32).toInt() % 32
// 4bit値(0-15)を -1.0〜1.0 に正規化
return (waveTable[index] - 7.5f) / 7.5f
}
}
三角波、ノコギリ波、さらにはオリジナルの波形も作れます。ベースラインやリード音に個性を出したい時に重宝します。
チャンネル4:ノイズ
ドラムやパーカッションに使うノイズチャンネル。LFSR(線形帰還シフトレジスタ)で生成します。
class NoiseChannel {
private var lfsr = 0x7FFF // 15bit
private var use7BitMode = false
fun clock(): Float {
val bit = (lfsr xor (lfsr shr 1)) and 1
lfsr = lfsr shr 1
if (use7BitMode) {
// 7bitモード:より金属的な音
lfsr = (lfsr and 0x3FBF) or (bit shl 6)
}
lfsr = lfsr or (bit shl 14)
return if (lfsr and 1 == 1) 1f else -1f
}
}
15bitモードは「シャー」という自然なノイズ、7bitモードは「ジー」という金属的なノイズになります。ハイハットとスネアで使い分けると効果的です。
エンベロープ(音量変化)
全チャンネル共通で重要なのがエンベロープです。音の立ち上がりや減衰を制御します。
data class Envelope(
val initialVolume: Int, // 0-15
val direction: Int, // 0=減衰, 1=増加
val stepTime: Int // 変化速度(0=無効, 1-7)
)
class EnvelopeGenerator(private val envelope: Envelope) {
private var volume = envelope.initialVolume
private var timer = 0
fun tick() {
if (envelope.stepTime == 0) return
timer++
if (timer >= envelope.stepTime * ENVELOPE_PERIOD) {
timer = 0
volume = if (envelope.direction == 1) {
(volume + 1).coerceAtMost(15)
} else {
(volume - 1).coerceAtLeast(0)
}
}
}
fun getVolume(): Float = volume / 15f
}
Androidでの実装ポイント
AudioTrackの設定
低レイテンシで再生するには、AudioTrackの設定が重要です。
val sampleRate = 44100
val bufferSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_FLOAT
)
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(bufferSize)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
PERFORMANCE_MODE_LOW_LATENCYを指定することで、タップしてから音が鳴るまでの遅延を最小化できます。
リアルタイム合成
4チャンネルをリアルタイムでミックスする処理は、別スレッドで行います。
private fun audioThread() {
android.os.Process.setThreadPriority(
android.os.Process.THREAD_PRIORITY_URGENT_AUDIO
)
val buffer = FloatArray(BUFFER_SIZE)
while (isPlaying) {
// 各チャンネルのサンプルを生成
for (i in buffer.indices) {
buffer[i] = (
channel1.getSample() +
channel2.getSample() +
channel3.getSample() +
channel4.getSample()
) / 4f // ミキシング
}
audioTrack.write(buffer, 0, buffer.size, AudioTrack.WRITE_BLOCKING)
}
}
実装して学んだこと
制約が生む創造性
Game Boyの4チャンネルという制限は、一見すると不便に思えます。でも実際に実装してみると、この制約があるからこそ、作曲者は工夫を凝らし、あの独特のサウンドが生まれることがわかりました。
デジタル信号処理の基礎
サウンドエンジンの実装を通じて、サンプリングレート、エイリアシング、位相など、デジタル信号処理の基礎を実践的に学べました。
レトロゲーム開発者へのリスペクト
限られたハードウェアで、あれだけ印象的な音楽を作っていた当時の開発者・作曲者の技術力に、改めて敬意を感じます。
まとめ
Game Boyサウンドエンジンの実装は、レトロゲーム愛とプログラミングが交差する楽しいプロジェクトでした。
PocketTuneでは、この仕組みを使って誰でも簡単にチップチューンを作れるようにしています。興味のある方は、ぜひ試してみてください。
