AndroidでGame Boyサウンドエンジンを実装する方法

Android
Screenshot

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では、この仕組みを使って誰でも簡単にチップチューンを作れるようにしています。興味のある方は、ぜひ試してみてください。

PocketTune – Google Playでダウンロード

タイトルとURLをコピーしました