In this chapter, we’re diving into the fascinating world of sound emulation for our Game Boy project. While often overlooked, a truly accurate emulator needs to replicate the distinct chiptune sounds that define the Game Boy experience. We’ll start by tackling the foundational elements of the Audio Processing Unit (APU), specifically focusing on its two square wave channels.
This milestone is critical because it brings our emulator to life in a new dimension. Hearing the familiar bleeps and boops of a Game Boy game validates our CPU and MMU work in a very tangible way. By the end of this chapter, you’ll have a basic APU implementation capable of generating square wave sounds, hooked into your emulator’s main loop, and outputting audio via SDL2’s direct audio queuing API.
Project Overview
Our overarching goal is to build a functional Game Boy emulator from scratch. So far, we’ve established the CPU core, memory management unit (MMU), and picture processing unit (PPU), allowing us to execute ROMs and render graphics. This chapter extends our emulator’s capabilities by introducing sound, a crucial component for a complete and immersive emulation experience. We’re building a modular system where each hardware component is distinct yet integrated, reflecting real hardware design.
Tech Stack
For this chapter, our primary tools remain consistent with the project:
- F#: The core language for its functional paradigm, which helps manage complex state transitions predictably.
- .NET 8.0: The runtime and SDK, providing a robust, cross-platform environment.
- SDL2: A cross-platform development library designed to provide low-level access to audio, keyboard, mouse, joystick, and graphics hardware. We’ll specifically leverage its audio capabilities. For F#/.NET, we use
SDL2-CSbindings, often found via NuGet asVelcroPhysics.SDL.Net.
Latest Versions (as of 2026-05-05):
- F#: The latest stable version of F# is typically included with the .NET SDK 8.0.
- Official F# Language Reference: https://learn.microsoft.com/en-us/dotnet/fsharp/
- .NET SDK: The latest Long Term Support (LTS) version is .NET 8.0.
- Download .NET SDK: https://dotnet.microsoft.com/download
- SDL2: The latest official SDL2 stable release is 2.30.2. For F#/.NET, we use
SDL2-CS(often found via NuGet asVelcroPhysics.SDL.Netor similar bindings).- Official SDL Documentation: https://wiki.libsdl.org/
- SDL2-CS GitHub (flibitijibibo): https://github.com/flibitijibibo/SDL2-CS
- Game Boy APU Documentation (Pan Docs): The definitive resource for Game Boy hardware specifics.
- Pan Docs - Sound Controller: https://gbdev.io/pandocs/Sound_Controller.html
APU Design and Build Plan
The Game Boy’s APU is a separate hardware component responsible for generating sound. It features four distinct sound channels: two pulse (square) waves, one wave (custom waveform), and one noise channel. Each channel has its own set of registers that control its frequency, volume, duty cycle, envelope, and length.
For this chapter, we’ll concentrate on the two square wave channels (often referred to as Channel 1 and Channel 2). These are controlled by specific memory-mapped I/O registers from 0xFF10 to 0xFF14 (Channel 1) and 0xFF15 to 0xFF19 (Channel 2).
Architecture Overview
The core challenge of APU emulation lies in synchronizing the sound generation with the CPU’s execution. The CPU runs at ~4.19 MHz, while audio typically samples at rates like 44.1 kHz or 48 kHz. We need to generate audio samples at the correct rate based on the APU’s internal state, which is updated by CPU writes to APU registers.
Our design will involve:
ApuState: An F# record to hold the current state of all APU channels and control registers.- Square Wave Channel State: A sub-record for each square wave channel, containing its specific parameters (frequency, duty cycle, volume, timers).
- Register Mapping: Functions to handle CPU writes to APU I/O registers, parsing the bitfields and updating the
ApuState. - Sample Generation: A function that, given the current APU state, can generate a single audio sample at the desired output sample rate.
- Audio Output: Using SDL2’s
SDL_QueueAudiofunction to manage an audio buffer and play the generated samples directly. This is preferred overSDL_Mixerfor raw sample streaming as it provides more granular control and avoids overhead designed for pre-recorded audio files.
Here’s a high-level data flow for the APU integration:
This diagram illustrates how CPU instructions trigger memory writes, which the MMU routes to the APU. The APU’s internal state is then updated, leading to sample generation and eventual output through SDL2.
F# Domain Modeling
We’ll define immutable records to represent the state of our APU and its individual channels. This aligns perfectly with F#’s functional paradigm.
SquareWaveChannel:
Enabled:boolFrequency:int(derived from NR13/NR14 registers)DutyCycle:int(0-3, representing 12.5%, 25%, 50%, 75% high)Volume:int(0-15)EnvelopeVolume:int(current volume after envelope processing)EnvelopeDirection:int(0: decrease, 1: increase)EnvelopePeriod:int(how often volume changes)LengthCounter:int(how many CPU cycles until channel stops)WavePosition:int(internal counter for waveform generation, 0-7 for 8-step duty)Timer:int(internal frequency timer)CurrentTimer:int(current countdown for frequency)SweepPeriod:int(Channel 1 only, how often frequency changes)SweepShift:int(Channel 1 only, how much frequency changes)SweepDirection:int(Channel 1 only, 0: increase, 1: decrease)ShadowFrequency:int(Channel 1 only, used by sweep unit)SweepEnabled:bool(Channel 1 only, true if sweep is active)
ApuState:
Channel1:SquareWaveChannelChannel2:SquareWaveChannelMasterVolumeLeft:intMasterVolumeRight:intEnabled:bool(Master APU enable, NR52 bit 7)SampleRate:int(e.g., 44100 Hz)CpuCyclesPerSample:float(how many CPU cycles correspond to one audio sample)PendingCpuCycles:float(accumulator for CPU cycles, to decide when to generate a sample)SampleBuffer:ResizeArray<float>(buffer to store generated samples)
Setup Requirements for SDL2 Audio
To use SDL2 for audio output, you’ll need the core SDL2 library.
Install
SDL2:- Windows: Download
SDL2-devel-2.x.x-vc.zipfrom the SDL releases page. Extract it and copySDL2.dll(fromlib/x64orlib/x86) into your project’s output directory (e.g.,bin/Debug/net8.0). - macOS:
brew install sdl2 - Linux:
sudo apt-get install libsdl2-dev
- Windows: Download
Add NuGet package: In your F# application project (
GbEmulator.Appor similar), add theVelcroPhysics.SDL.NetNuGet package, which provides the necessary C# bindings for SDL2.dotnet add GbEmulator.App package VelcroPhysics.SDL.Net
Step-by-Step Implementation
We’ll start by defining our APU state and basic register write logic.
0. Define Constants in GbEmulator.Domain/Constants.fs
First, ensure you have a Constants.fs file in your GbEmulator.Domain project with common constants, including the CPU speed.
File: GbEmulator.Domain/Constants.fs
namespace GbEmulator.Domain
module Constants =
let CpuSpeedHz = 4_194_304 // Game Boy CPU runs at ~4.19 MHz
let FramesPerSecond = 60
let CyclesPerInstruction = 4 // Average cycles per instruction (varies, but 4 is a common base)
let WramSize = 0x2000 // 8KB
let HramSize = 0x80 // 128 bytes
let OamSize = 0xA0 // 160 bytes
let IoSize = 0x100 // 256 bytes for I/O registers
let VramSize = 0x2000 // 8KB
๐ Key Idea: Centralizing constants like CpuSpeedHz ensures consistency across the emulator and simplifies future adjustments.
1. Define APU State in GbEmulator.Domain/Apu.fs
Create a new file Apu.fs in your GbEmulator.Domain project. This file will hold all the necessary types and initialization logic for our APU.
File: GbEmulator.Domain/Apu.fs
namespace GbEmulator.Domain
open System
open System.Collections.Generic // For ResizeArray
// --- Square Wave Channel State ---
type SquareWaveChannel = {
Enabled : bool
Frequency : int // Derived from NR13/NR14. Game Boy frequency is 131072 / (2048 - frequency) Hz
DutyCycle : int // 0=12.5%, 1=25%, 2=50%, 3=75%
Volume : int // Initial volume for envelope (0-15)
EnvelopeVolume : int // Current volume after envelope processing (0-15)
EnvelopeDirection : int // 0=Decrease, 1=Increase
EnvelopePeriod : int // Number of 1/64s of a second between volume changes (0=no envelope)
LengthCounter : int // Number of 1/256s of a second before channel stops
LengthEnabled : bool // True if length counter is enabled
WavePosition : int // Internal counter for waveform generation (0-7 for 8-step duty cycle)
Timer : int // Internal frequency timer (reload value)
CurrentTimer : int // Current countdown for frequency
SweepPeriod : int // Channel 1 only: number of 1/128s of a second between sweep updates
SweepShift : int // Channel 1 only: number of bits to shift frequency for sweep
SweepDirection : int // Channel 1 only: 0=Increase, 1=Decrease (frequency)
ShadowFrequency : int // Channel 1 only: used by sweep unit
SweepEnabled : bool // Channel 1 only: true if sweep is active
}
// --- APU Master State ---
type ApuState = {
Channel1 : SquareWaveChannel
Channel2 : SquareWaveChannel
MasterVolumeLeft : int // Master volume for left output (0-7)
MasterVolumeRight : int // Master volume for right output (0-7)
Enabled : bool // Master APU enable (NR52 bit 7)
SampleRate : int // Target audio sample rate (e.g., 44100 Hz)
CpuCyclesPerSample : float // How many CPU cycles correspond to one audio sample
PendingCpuCycles : float // Accumulator for CPU cycles, to decide when to generate a sample
SampleBuffer : ResizeArray<float> // Buffer to store generated samples
}
// Initial state for a square wave channel
let private initialSquareWaveChannel = {
Enabled = false; Frequency = 0; DutyCycle = 0; Volume = 0; EnvelopeVolume = 0;
EnvelopeDirection = 0; EnvelopePeriod = 0; LengthCounter = 0; LengthEnabled = false;
WavePosition = 0; Timer = 0; CurrentTimer = 0; SweepPeriod = 0; SweepShift = 0;
SweepDirection = 0; ShadowFrequency = 0; SweepEnabled = false
}
// Initialize the APU state
let initApu (sampleRate: int) = {
Channel1 = initialSquareWaveChannel
Channel2 = initialSquareWaveChannel
MasterVolumeLeft = 0
MasterVolumeRight = 0
Enabled = false
SampleRate = sampleRate
CpuCyclesPerSample = Constants.CpuSpeedHz |> float / float sampleRate
PendingCpuCycles = 0.0
SampleBuffer = ResizeArray<float>()
}
๐ Key Idea: We define detailed state records for each square wave channel and the overall APU. This structured approach makes it easier to manage the complex interactions of the APU registers, leveraging F#’s immutability for predictable state changes. ResizeArray<float> is chosen for the sample buffer to allow efficient appending of new samples without constant reallocations, which is important for performance-critical audio loops.
2. Add APU to Emulator State
To integrate the APU into our emulator, its state must be part of the global EmulatorState. Open GbEmulator.Core/Emulator.fs and add ApuState to your EmulatorState record and the initEmulator function.
File: GbEmulator.Core/Emulator.fs
namespace GbEmulator.Core
open GbEmulator.Domain
open GbEmulator.Domain.Cpu
open GbEmulator.Domain.Memory
open GbEmulator.Domain.Ppu
open GbEmulator.Domain.Apu // Add this line
// ... other record definitions
type EmulatorState = {
CpuState: CpuState
MmuState: MmuState
PpuState: PpuState
ApuState: ApuState // Add this field
// ... other fields
Running: bool
}
// ... in initEmulator function ...
let initEmulator (romBytes: byte[]) (sampleRate: int) =
let mmu = Mmu.initMmu romBytes
let cpu = Cpu.initCpu
let ppu = Ppu.initPpu
let apu = Apu.initApu sampleRate // Initialize APU with the target sample rate
{ CpuState = cpu; MmuState = mmu; PpuState = ppu; ApuState = apu; Running = true } // Add apu
โก Quick Note: We pass the sampleRate to Apu.initApu because the APU needs to know the target audio output rate to calculate CpuCyclesPerSample accurately. This is crucial for correct timing synchronization.
3. Handle APU Register Writes in GbEmulator.Domain/Mmu.fs
The MMU is responsible for routing memory writes to the correct hardware components. We need to extend Mmu.writeByte to update the APU state when writes occur to the APU’s I/O registers (0xFF10 - 0xFF3F).
First, in GbEmulator.Domain/Apu.fs, add functions to update the APU state based on register writes. For now, we’ll focus on the raw register values, and later derive the actual sound parameters.
File: GbEmulator.Domain/Apu.fs (continued)
// GbEmulator.Domain/Apu.fs (continued)
// Helper for bit manipulation
let private isBitSet value bit = (value &&& (1 <<< bit)) <> 0
// --- APU Register Write Handlers ---
// NR10 - Channel 1 Sweep register
let writeNr10 (value: byte) (channel: SquareWaveChannel) =
{ channel with
SweepPeriod = (value >>> 4) &&& 0x7 // Bits 4-6
SweepDirection = (value >>> 3) &&& 0x1 // Bit 3 (0=increase, 1=decrease)
SweepShift = value &&& 0x7 // Bits 0-2
}
// NR11/NR21 - Channel 1/2 Length & Duty Cycle
let writeNrX1 (value: byte) (channel: SquareWaveChannel) =
{ channel with
DutyCycle = (value >>> 6) &&& 0x3 // Bits 6-7
LengthCounter = 64 - (value &&& 0x3F) // Bits 0-5. Length is 64 - N.
}
// NR12/NR22 - Channel 1/2 Volume & Envelope
let writeNrX2 (value: byte) (channel: SquareWaveChannel) =
{ channel with
Volume = (value >>> 4) &&& 0xF // Bits 4-7
EnvelopeDirection = (value >>> 3) &&& 0x1 // Bit 3 (0=decrease, 1=increase)
EnvelopePeriod = value &&& 0x7 // Bits 0-2
// If initial volume is 0, channel is muted immediately.
Enabled = ((value >>> 4) &&& 0xF) <> 0 // Channel enabled only if initial volume is not 0
EnvelopeVolume = (value >>> 4) &&& 0xF // Set current envelope volume to initial volume
}
// NR13/NR23 - Channel 1/2 Frequency Low (lower 8 bits of 11-bit frequency)
let writeNrX3 (value: byte) (channel: SquareWaveChannel) =
// The frequency is an 11-bit value, so we combine with the upper bits from NR14
let newFrequency = (channel.Frequency &&& 0x700) ||| (int value) // Keep upper 3 bits, set lower 8
{ channel with
Frequency = newFrequency
// Timer calculation: (2048 - frequency) * 4 CPU cycles. This is the period of one full waveform.
Timer = (2048 - newFrequency) * 4
CurrentTimer = (2048 - newFrequency) * 4 // Reset current timer
}
// NR14/NR24 - Channel 1/2 Frequency High (upper 3 bits) & Control
let writeNrX4 (value: byte) (channel: SquareWaveChannel) =
// Update upper 3 bits of frequency
let newFrequency = (channel.Frequency &&& 0xFF) ||| (((int (value &&& 0x7)) <<< 8)) // Bits 0-2
{ channel with
Frequency = newFrequency
LengthEnabled = isBitSet value 6 // Bit 6: Use length counter
// Trigger bit (Bit 7): When set, reloads length counter, volume envelope, and frequency timer.
// This is where the channel is "restarted".
Enabled = if isBitSet value 7 then ((channel.Volume &&& 0xF) <> 0) else channel.Enabled // Trigger enables if volume > 0
LengthCounter = if isBitSet value 7 then (if channel.LengthCounter = 0 then 64 else channel.LengthCounter) else channel.LengthCounter
EnvelopeVolume = if isBitSet value 7 then channel.Volume else channel.EnvelopeVolume
CurrentTimer = if isBitSet value 7 then (2048 - newFrequency) * 4 else channel.CurrentTimer
Timer = if isBitSet value 7 then (2048 - newFrequency) * 4 else channel.Timer
ShadowFrequency = if isBitSet value 7 then newFrequency else channel.ShadowFrequency // For sweep unit
SweepEnabled = if isBitSet value 7 then (channel.SweepPeriod <> 0 || channel.SweepShift <> 0) else channel.SweepEnabled
}
// NR50 - Channel Control (Volume & Vin Panning)
let writeNr50 (value: byte) (apu: ApuState) =
{ apu with
MasterVolumeLeft = (value >>> 4) &&& 0x7 // Bits 4-6
MasterVolumeRight = value &&& 0x7 // Bits 0-2
}
// NR51 - Selection of Sound Output Terminal
let writeNr51 (value: byte) (apu: ApuState) =
// For now, we'll ignore panning, but this register controls which channels go to which speaker.
// Bit 0: Channel 1 to Right, Bit 1: Channel 2 to Right, etc.
// Bit 4: Channel 1 to Left, Bit 5: Channel 2 to Left, etc.
// This is a simplification; a real implementation would use this to mix channels.
apu
// NR52 - Sound ON/OFF
let writeNr52 (value: byte) (apu: ApuState) =
let newEnabled = isBitSet value 7
if not newEnabled then
// If master APU is disabled, all channels are disabled and their states are reset.
// This is a simplification for now. A full reset would zero out all channel registers.
{ apu with
Enabled = newEnabled
Channel1 = { apu.Channel1 with Enabled = false }
Channel2 = { apu.Channel2 with Enabled = false }
}
else
{ apu with Enabled = newEnabled }
// Main APU write function: Routes writes to specific channel handlers
let writeByte (addr: int) (value: byte) (apu: ApuState) : ApuState =
match addr with
| 0xFF10 -> { apu with Channel1 = writeNr10 value apu.Channel1 } // NR10 - Channel 1 Sweep
| 0xFF11 -> { apu with Channel1 = writeNrX1 value apu.Channel1 } // NR11 - Channel 1 Length & Duty
| 0xFF12 -> { apu with Channel1 = writeNrX2 value apu.Channel1 } // NR12 - Channel 1 Volume & Envelope
| 0xFF13 -> { apu with Channel1 = writeNrX3 value apu.Channel1 } // NR13 - Channel 1 Frequency Low
| 0xFF14 -> { apu with Channel1 = writeNrX4 value apu.Channel1 } // NR14 - Channel 1 Frequency High & Control
| 0xFF16 -> { apu with Channel2 = writeNrX1 value apu.Channel2 } // NR21 - Channel 2 Length & Duty
| 0xFF17 -> { apu with Channel2 = writeNrX2 value apu.Channel2 } // NR22 - Channel 2 Volume & Envelope
| 0xFF18 -> { apu with Channel2 = writeNrX3 value apu.Channel2 } // NR23 - Channel 2 Frequency Low
| 0xFF19 -> { apu with Channel2 = writeNrX4 value apu.Channel2 } // NR24 - Channel 2 Frequency High & Control
| 0xFF20 -> apu // NR30 - Channel 3 Sound On/Off (Wave Channel) - Placeholder
| 0xFF21 -> apu // NR31 - Channel 3 Length (Wave Channel) - Placeholder
| 0xFF22 -> apu // NR32 - Channel 3 Volume (Wave Channel) - Placeholder
| 0xFF23 -> apu // NR33 - Channel 3 Frequency Low (Wave Channel) - Placeholder
| 0xFF24 -> apu // NR34 - Channel 3 Frequency High (Wave Channel) - Placeholder
| 0xFF25 -> writeNr50 value apu // NR50 - Channel Control (Volume & Vin Panning)
| 0xFF26 -> writeNr51 value apu // NR51 - Selection of Sound Output Terminal
| 0xFF27 -> writeNr52 value apu // NR52 - Sound ON/OFF
| 0xFF30 .. 0xFF3F -> apu // Wave RAM - Placeholder for Channel 3's waveform data
| _ -> apu
๐ง Important: The writeNrX4 (trigger register) is crucial. When bit 7 is set, it effectively “restarts” the channel, reloading its length counter, volume envelope, and frequency timer. This is a common Game Boy sound programming technique. We also set Enabled = true on trigger, but the channel can still be muted if its initial volume (NRx2) is zero.
Now, integrate this into GbEmulator.Domain/Mmu.fs. This requires modifying the writeByte function to accept and return ApuState alongside MmuState.
File: GbEmulator.Domain/Mmu.fs
namespace GbEmulator.Domain
open GbEmulator.Domain.Apu // Add this line
// ... other open statements
type MmuState = {
// ... existing fields
Rom: byte[]
Wram: byte[]
Hram: byte[]
Oam: byte[]
Io: byte[] // For I/O registers
Vram: byte[] // Ensure Vram is here if not already
}
// ... in initMmu ...
let initMmu (romBytes: byte[]) =
{
// ... existing initialization
Rom = romBytes
Wram = Array.zeroCreate Constants.WramSize
Hram = Array.zeroCreate Constants.HramSize
Oam = Array.zeroCreate Constants.OamSize
Io = Array.zeroCreate Constants.IoSize // Initialize I/O registers
Vram = Array.zeroCreate Constants.VramSize // Initialize Vram
}
// Modify the writeByte function to handle APU registers
let writeByte (addr: int) (value: byte) (mmu: MmuState) (apu: ApuState) : MmuState * ApuState =
match addr with
| 0x0000 .. 0x7FFF -> // ROM
// Handle MBC banking here later
(mmu, apu)
| 0x8000 .. 0x9FFF -> // VRAM
let newVram = Array.copy mmu.Vram
newVram.[addr - 0x8000] <- value
({ mmu with Vram = newVram }, apu)
| 0xA000 .. 0xBFFF -> // External RAM
// Handle external RAM banking here later
(mmu, apu)
| 0xC000 .. 0xDFFF -> // WRAM
let newWram = Array.copy mmu.Wram
newWram.[addr - 0xC000] <- value
({ mmu with Wram = newWram }, apu)
| 0xE000 .. 0xFDFF -> // Echo RAM (mirror of C000-DDFF)
// Writes to echo RAM also write to WRAM
let wramAddr = addr - 0xE000 + 0xC000
let newWram = Array.copy mmu.Wram
newWram.[wramAddr - 0xC000] <- value
({ mmu with Wram = newWram }, apu)
| 0xFE00 .. 0xFE9F -> // OAM
let newOam = Array.copy mmu.Oam
newOam.[addr - 0xFE00] <- value
({ mmu with Oam = newOam }, apu)
| 0xFF00 -> // P1 - Joypad
// Joypad is read-only for writes, except for selecting button/direction bits
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x00] <- (arr.[0x00] &&& 0xCF) ||| (value &&& 0x30); arr }, apu)
| 0xFF01 -> // SB - Serial Transfer Data
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x01] <- value; arr }, apu)
| 0xFF04 -> // DIV - Divider Register
// Resetting DIV is a special case, it always writes 0
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x04] <- 0x00; arr }, apu) // DIV is reset to 0 on write
| 0xFF05 -> // TIMA - Timer Counter
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x05] <- value; arr }, apu)
| 0xFF06 -> // TMA - Timer Modulo
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x06] <- value; arr }, apu)
| 0xFF07 -> // TAC - Timer Control
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x07] <- value; arr }, apu)
| 0xFF0F -> // IF - Interrupt Flag
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0x0F] <- value; arr }, apu)
| 0xFF10 .. 0xFF27 -> // APU Registers
// Route APU register writes to the APU module
(mmu, Apu.writeByte addr value apu)
| 0xFF28 .. 0xFF2F -> // Unused I/O (APU)
(mmu, apu)
| 0xFF30 .. 0xFF3F -> // Wave RAM (APU)
// Route Wave RAM writes to the APU module (for Channel 3)
// For now, these are placeholders, but Apu.writeByte will handle them eventually.
(mmu, Apu.writeByte addr value apu)
| 0xFF40 .. 0xFF4B -> // PPU Registers
// Update the PPU state based on register writes
let (newIo, newLcdControl, newScrollY, newScrollX, newWindowY, newWindowX) =
Ppu.writePpuRegister (addr - 0xFF40) value mmu.Io
// Update MMU's Io array for the PPU register
let updatedIo = (Array.copy mmu.Io) |> fun arr -> arr.[addr - 0xFF00] <- value; arr
( { mmu with Io = updatedIo }, apu)
| 0xFF4C .. 0xFF7F -> // Unused I/O, but some are used in CGB mode
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[addr - 0xFF00] <- value; arr }, apu)
| 0xFF80 .. 0xFFFE -> // HRAM
let newHram = Array.copy mmu.Hram
newHram.[addr - 0xFF80] <- value
({ mmu with Hram = newHram }, apu)
| 0xFFFF -> // IE - Interrupt Enable
({ mmu with Io = (Array.copy mmu.Io) |> fun arr -> arr.[0xFF] <- value; arr }, apu)
| _ ->
printfn "MMU: Unhandled write to 0x%x with value 0x%x" addr value
(mmu, apu)
โก Quick Note: Mmu.writeByte now returns MmuState * ApuState. This is a common functional pattern for updating multiple related states. This means the Cpu.executeInstruction function will need to be refactored to accept and return ApuState as well, ensuring APU state is always current. We also added a small fix for the joypad write to only allow writing to the upper nibble (direction/button select).
4. Implement Sample Generation and APU Tick
Back in GbEmulator.Domain/Apu.fs, we need functions to advance the APU’s internal state and generate audio samples.
File: GbEmulator.Domain/Apu.fs (continued)
// GbEmulator.Domain/Apu.fs (continued)
// Duty cycle waveforms (8 steps, true=high, false=low)
let private dutyCycles =
[|
[|false; false; false; false; false; false; false; true|] // 12.5%
[|true; false; false; false; false; false; false; true|] // 25%
[|true; false; false; false; false; true; false; true|] // 50%
[|false; true; true; true; true; true; true; false|] // 75%
|]
// Generate a single sample for a square wave channel
let generateSquareWaveSample (channel: SquareWaveChannel) : float =
if not channel.Enabled || channel.EnvelopeVolume = 0 then
0.0 // Muted if channel disabled or volume is 0
else
let dutyPattern = dutyCycles.[channel.DutyCycle]
// Normalize volume to a float between 0 and 1
let normalizedVolume = float channel.EnvelopeVolume / 15.0
if dutyPattern.[channel.WavePosition] then
normalizedVolume // High state
else
-normalizedVolume // Low state (for bipolar output, centered at 0)
// Update internal timers and generate samples
let tick (cpuCycles: int) (apu: ApuState) : ApuState =
if not apu.Enabled then
// If master APU is disabled, clear sample buffer and return
apu.SampleBuffer.Clear()
{ apu with PendingCpuCycles = 0.0 }
else
let mutable currentApu = apu
let mutable currentChannel1 = apu.Channel1
let mutable currentChannel2 = apu.Channel2
// Accumulate CPU cycles
currentApu <- { currentApu with PendingCpuCycles = currentApu.PendingCpuCycles + float cpuCycles }
// Process frame sequencer (length counter, envelope, sweep - simplified for now)
// This is a complex part of the APU, typically running at 512 Hz (every 8192 CPU cycles)
// For simplicity, we'll implement a basic frequency timer and waveform step.
// Full frame sequencer logic is for a later, more accurate APU chapter.
// --- Channel 1 & 2 Frequency Timer and Wave Position Update ---
let updateChannelTimer (channel: SquareWaveChannel) =
if channel.Enabled && channel.Timer > 0 then // Only tick if enabled and timer is valid
let newCurrentTimer = channel.CurrentTimer - cpuCycles
if newCurrentTimer <= 0 then
// Timer expired, reload and advance wave position
let timerReload = channel.Timer // Use the pre-calculated timer value
let newWavePosition = (channel.WavePosition + 1) % 8
{ channel with CurrentTimer = timerReload + newCurrentTimer; WavePosition = newWavePosition }
else
{ channel with CurrentTimer = newCurrentTimer }
else
channel // Not enabled or timer is 0, no change
currentChannel1 <- updateChannelTimer currentChannel1
currentChannel2 <- updateChannelTimer currentChannel2
// --- Sample Generation Loop ---
while currentApu.PendingCpuCycles >= currentApu.CpuCyclesPerSample do
// Generate a sample from each enabled channel
let sample1 = generateSquareWaveSample currentChannel1
let sample2 = generateSquareWaveSample currentChannel2
// Basic mixing: sum and clip (or normalize)
let mixedSample = (sample1 + sample2) / 2.0 // Simple average for now
currentApu.SampleBuffer.Add(mixedSample)
currentApu <- { currentApu with PendingCpuCycles = currentApu.PendingCpuCycles - currentApu.CpuCyclesPerSample }
{ currentApu with Channel1 = currentChannel1; Channel2 = currentChannel2 }
โ ๏ธ What can go wrong: The tick function for the APU is extremely simplified here. A real Game Boy APU has a complex “frame sequencer” that manages length counters, volume envelopes, and sweep units at specific clock rates (e.g., 512 Hz). Our current tick only advances the frequency timer and waveform position. This will produce static square waves, not the dynamic sounds with fading volumes and changing pitches found in real games. We’ll address this in a future chapter for higher accuracy.
โก Real-world insight: The Game Boy’s APU is notoriously tricky to emulate accurately due to its tight synchronization requirements and the specific behavior of its frame sequencer. Many emulators start with this simplified approach and gradually add more cycle-accurate logic for length, envelope, and sweep.
5. Update Emulator’s Main Loop
To ensure ApuState is consistently updated, we need to pass it through functions that modify memory-mapped I/O registers. This means refactoring Cpu.executeInstruction to accept and return ApuState.
First, refactor GbEmulator.Domain/Cpu.fs:
Locate your Cpu.executeInstruction function and modify its signature and internal calls to Mmu.writeByte.
File: GbEmulator.Domain/Cpu.fs (partial, illustrating refactor)
namespace GbEmulator.Domain
open GbEmulator.Domain.Memory
open GbEmulator.Domain.Apu
// ... other open statements
// Define the return type to include ApuState
type CpuExecutionResult = {
CpuState: CpuState
MmuState: MmuState
ApuState: ApuState // New: Include ApuState in the result
Cycles: int
}
// Refactor executeInstruction signature
let executeInstruction (cpu: CpuState) (mmu: MmuState) (apu: ApuState) : CpuExecutionResult =
// ... existing instruction fetch and decode logic ...
// Example: When an instruction performs a memory write (e.g., LD (HL), A)
// Assume `addr` and `value` are determined by the instruction
let (newMmu, newApu) = Mmu.writeByte addr value currentMmu currentApu // Pass and receive ApuState
// ... continue with instruction execution, updating cpu state, mmu, and apu ...
// When returning the result:
{ CpuState = updatedCpu; MmuState = newMmu; ApuState = newApu; Cycles = instructionCycles }
โก Real-world insight: Propagating state updates through multiple modules (Cpu, Mmu, Apu) in a functional way requires careful thought. In F#, you typically pass the current state to a function and it returns a new state. This is why Mmu.writeByte now returns MmuState * ApuState, and Cpu.executeInstruction needs to incorporate ApuState in its signature and return type. This ensures the ApuState reflects any register writes immediately.
Now, update GbEmulator.Core/Emulator.fs:
Modify the step function to correctly call the refactored Cpu.executeInstruction and handle the updated ApuState.
File: GbEmulator.Core/Emulator.fs (continued)
namespace GbEmulator.Core
open GbEmulator.Domain
open GbEmulator.Domain.Cpu
open GbEmulator.Domain.Memory
open GbEmulator.Domain.Ppu
open GbEmulator.Domain.Apu
open System
let step (state: EmulatorState) : EmulatorState =
// ... existing code ...
// Call the refactored Cpu.executeInstruction with ApuState
let cpuExecResult = Cpu.executeInstruction state.CpuState state.MmuState state.ApuState
let newCpuState = cpuExecResult.CpuState
let newMmuState = cpuExecResult.MmuState
let updatedApuFromCpu = cpuExecResult.ApuState // APU state after CPU writes (via MMU)
let newPpuState = Ppu.tick cpuExecResult.Cycles state.PpuState newMmuState.Io // PPU tick
let finalApuState = Apu.tick cpuExecResult.Cycles updatedApuFromCpu // APU tick for internal timing
{ state with
CpuState = newCpuState
MmuState = newMmuState
PpuState = newPpuState
ApuState = finalApuState // Update with the final APU state
}
6. SDL2 Audio Integration in GbEmulator.App/Program.fs
This is where we actually play the sound. We’ll use SDL2’s direct audio queuing.
File: GbEmulator.App/Program.fs
namespace GbEmulator.App
open System
open System.Runtime.InteropServices
open SDL2
open GbEmulator.Core
open GbEmulator.Domain
open GbEmulator.Domain.Ppu
open System.IO // For File.ReadAllBytes
// --- SDL2 Audio P/Invoke declarations ---
[<DllImport("SDL2.dll", CallingConvention = CallingConvention.Cdecl)>]
extern uint SDL_OpenAudioDevice(string device, int iscapture, ref SDL.SDL_AudioSpec desired, out SDL.SDL_AudioSpec obtained, int allowed_changes)
[<DllImport("SDL2.dll", CallingConvention = CallingConvention.Cdecl)>]
extern int SDL_QueueAudio(uint dev, IntPtr data, uint len)
[<DllImport("SDL2.dll", CallingConvention = CallingConvention.Cdecl)>]
extern uint SDL_GetQueuedAudioSize(uint dev)
[<DllImport("SDL2.dll", CallingConvention = CallingConvention.Cdecl)>]
extern void SDL_PauseAudioDevice(uint dev, int pause_on)
[<DllImport("SDL2.dll", CallingConvention = CallingConvention.Cdecl)>]
extern void SDL_CloseAudioDevice(uint dev)
// Define audio parameters
let AudioFrequency = 44100 // Hz, common sample rate
let AudioChannels = 2 // Stereo output
let AudioBufferSize = 2048 // Samples per internal SDL buffer (power of 2 is good)
let AudioFormat = 0x8010us // AUDIO_S16SYS (Signed 16-bit, system endian)
// --- Main Program Loop ---
[<EntryPoint>]
let main argv =
// Check for ROM path argument
if argv.Length = 0 then
printfn "Usage: dotnet run --project GbEmulator.App -- <path_to_rom.gb>"
exit 1
// ... existing SDL initialization ...
if SDL.SDL_Init(SDL.SDL_INIT_VIDEO ||| SDL.SDL_INIT_AUDIO) < 0 then
printfn "SDL_Init Error: %s" (SDL.SDL_GetError())
exit 1
// Global variable for audio device ID
let mutable audioDeviceId: uint = 0u
let desiredAudioSpec =
{ SDL.SDL_AudioSpec.freq = AudioFrequency
SDL.SDL_AudioSpec.format = AudioFormat
SDL.SDL_AudioSpec.channels = byte AudioChannels
SDL.SDL_AudioSpec.samples = UInt16 AudioBufferSize // Buffer size in samples
SDL.SDL_AudioSpec.padding = 0us
SDL.SDL_AudioSpec.size = 0u
SDL.SDL_AudioSpec.callback = IntPtr.Zero // No callback for queueing
SDL.SDL_AudioSpec.userdata = IntPtr.Zero
}
let mutable obtainedAudioSpec = desiredAudioSpec // Will be filled by SDL
audioDeviceId <- SDL_OpenAudioDevice(null, 0, &desiredAudioSpec, &obtainedAudioSpec, SDL.SDL_AUDIO_ALLOW_FORMAT_CHANGE)
if audioDeviceId = 0u then
printfn "SDL_OpenAudioDevice Error: %s" (SDL.SDL_GetError())
SDL.SDL_Quit()
exit 1
printfn "SDL_AudioDevice opened successfully. Frequency: %d, Channels: %d" obtainedAudioSpec.freq (int obtainedAudioSpec.channels)
// Start audio playback
SDL_PauseAudioDevice(audioDeviceId, 0) // Unpause
// ... create window and renderer ...
let window = SDL.SDL_CreateWindow("Game Boy Emulator", SDL.SDL_WINDOWPOS_CENTERED, SDL.SDL_WINDOWPOS_CENTERED, Ppu.ScreenWidth * 3, Ppu.ScreenHeight * 3, SDL.SDL_WindowFlags.SDL_WINDOW_SHOWN)
let renderer = SDL.SDL_CreateRenderer(window, -1, SDL.SDL_RendererFlags.SDL_RENDERER_ACCELERATED)
let texture = SDL.SDL_CreateTexture(renderer, SDL.SDL_PIXELFORMAT_ARGB8888, int SDL.SDL_TextureAccess.SDL_TEXTUREACCESS_STREAMING, Ppu.ScreenWidth, Ppu.ScreenHeight)
let romPath = argv.[0]
let romBytes = File.ReadAllBytes romPath
let mutable emulatorState = Emulator.initEmulator romBytes AudioFrequency // Pass sample rate to APU
let mutable lastFrameTime = DateTime.Now
let rec loop (state: EmulatorState) =
// ... event handling ...
let event = SDL.SDL_Event()
if SDL.SDL_PollEvent(&event) <> 0 then
match event.type with
| SDL.SDL_EventType.SDL_QUIT -> { state with Running = false }
| SDL.SDL_EventType.SDL_KEYDOWN ->
match event.key.keysym.sym with
| SDL.SDL_Keycode.SDLK_ESCAPE -> { state with Running = false }
| _ -> state // Handle other key presses for input
| _ -> state
else
let now = DateTime.Now
let deltaTime = (now - lastFrameTime).TotalSeconds
lastFrameTime <- now
// Run emulator steps
let mutable currentState = state
// Aim for 60 FPS. CPU Speed Hz / 60 frames per second = cycles per frame.
// Run in chunks to avoid single massive loop.
let cyclesPerFrame = Constants.CpuSpeedHz / Constants.FramesPerSecond
let mutable cyclesThisFrame = 0
while cyclesThisFrame < cyclesPerFrame do
// Execute one CPU instruction, updating CPU, MMU, and APU states
let execResult = Cpu.executeInstruction currentState.CpuState currentState.MmuState currentState.ApuState
// Update PPU and APU with the cycles consumed by the instruction
let newPpuState = Ppu.tick execResult.Cycles currentState.PpuState currentState.MmuState.Io
let newApuState = Apu.tick execResult.Cycles execResult.ApuState // APU tick after CPU cycles
currentState <- { currentState with
CpuState = execResult.CpuState
MmuState = execResult.MmuState
PpuState = newPpuState
ApuState = newApuState // Update with the final APU state
}
cyclesThisFrame <- cyclesThisFrame + execResult.Cycles
// PPU rendering
Ppu.renderFrame currentState.PpuState renderer texture
// Audio output: Copy samples from APU buffer to SDL2 audio queue
let apuState = currentState.ApuState
let bytesQueued = SDL_GetQueuedAudioSize(audioDeviceId) // Get amount of audio currently in SDL's queue
let maxQueueBytes = uint32 (AudioFrequency * AudioChannels * sizeof<int16> * 2 / 60) // Roughly 2 frames of audio
// Queue more audio only if the buffer isn't too full, to avoid latency
if apuState.SampleBuffer.Count > 0 && bytesQueued < maxQueueBytes then
// Convert float samples to int16
let samplesToCopy = apuState.SampleBuffer.Count
let audioData = Array.zeroCreate<int16>(samplesToCopy * AudioChannels) // Stereo output
for i = 0 to samplesToCopy - 1 do
let sampleFloat = apuState.SampleBuffer.[i]
let sampleInt16 = (sampleFloat * 32767.0) |> int16 // Scale to 16-bit signed range
audioData.[i * AudioChannels] <- sampleInt16 // Left channel
audioData.[i * AudioChannels + 1] <- sampleInt16 // Right channel (simple stereo)
// Queue audio data
use handle = GCHandle.alloc audioData GCHandleType.Pinned
let ptr = handle.AddrOfPinnedObject()
let result = SDL_QueueAudio(audioDeviceId, ptr, uint (audioData.Length * sizeof<int16>()))
if result < 0 then
printfn "SDL_QueueAudio Error: %s" (SDL.SDL_GetError())
apuState.SampleBuffer.Clear() // Clear APU's internal buffer after queueing
// Limit frame rate to 60 FPS
let frameDuration = (DateTime.Now - now).TotalMilliseconds
let targetFrameTime = 1000.0 / float Constants.FramesPerSecond
if frameDuration < targetFrameTime then
SDL.SDL_Delay(uint32 (targetFrameTime - frameDuration))
{ currentState with Running = true } // Continue loop
// Start the main emulator loop
let finalState = loop emulatorState
// ... existing SDL cleanup ...
SDL_PauseAudioDevice(audioDeviceId, 1) // Pause audio
SDL_CloseAudioDevice(audioDeviceId) // Close audio device
SDL.SDL_DestroyTexture(texture)
SDL.SDL_DestroyRenderer(renderer)
SDL.SDL_DestroyWindow(window)
SDL.SDL_Quit()
0
๐ฅ Optimization / Pro tip: Using SDL_QueueAudio directly is generally preferred for emulators because it gives you precise control over the raw sample data and streaming. SDL_mixer is more suited for playing pre-made sound effects and music files, not for generating samples on the fly. The GCHandle.alloc is used to pin the F# array in memory so its address can be passed to the unmanaged SDL function. The use keyword ensures handle.Free() is called automatically when the handle goes out of scope, preventing memory leaks. We’ve also added a check for SDL_GetQueuedAudioSize to prevent overfilling the audio buffer, which can cause latency or glitches.
Testing & Verification
With the APU integrated, it’s time to hear our work!
- Run with a ROM that has simple sounds:
- Good candidates: Tetris, Dr. Mario, Alleyway. These games use square waves extensively for their music and sound effects.
- Command:
dotnet run --project GbEmulator.App -- path/to/tetris.gb
- Expected Behavior:
- You should hear basic square wave sounds. The pitch might be correct, but the volume envelopes and length counters will be very basic or non-existent in this initial implementation.
- Music might sound flat or continuous, lacking the dynamic changes you’d expect.
- Debugging Checks:
- No sound at all?
- Check
SDL_InitandSDL_OpenAudioDevicereturn values inProgram.fs. Any errors? - Is
apu.Enabled(NR52 bit 7) being set by the ROM? Log its value inApu.fs’swriteNr52. - Are square wave channels
Enabled(NRx2 bit 7, and NRx4 bit 7)? Log channelEnabledstate inApu.fs. - Is
apuState.SampleBuffer.Countincreasing? If not,Apu.tickisn’t generating samples. - Are
SDL_QueueAudiocalls successful? Any error messages printed?
- Check
- Garbled/choppy sound?
โ ๏ธ What can go wrong:This often indicates timing issues or buffer underruns/overruns. IsAudioFrequencymatching whatEmulator.initEmulatorandApu.initApuexpect?- Is
CpuCyclesPerSamplecalculated correctly? - Is the amount of data (in bytes) passed to
SDL_QueueAudiocorrect? (It should besamples * channels * sizeof<int16>). - Are you calling
Emulator.stepenough times per frame to generate enough samples to keep the audio buffer full? UseSDL_GetQueuedAudioSizeto monitor the buffer level. If it’s consistently low, your emulator isn’t feeding enough audio fast enough.
- Incorrect pitch?
- Double-check the
Frequencycalculation from NR13/NR14 registers (131072 / (2048 - frequency)). - Verify the
Timerreload value ((2048 - channel.Frequency) * 4). - Ensure
WavePositionis advancing correctly.
- Double-check the
- No sound at all?
Production Considerations
- Performance: Audio generation is a constant, real-time task. Our
Apu.tickfunction must be extremely lightweight. ThegenerateSquareWaveSampleis simple enough for now, but more complex channels (wave, noise) and accurate frame sequencer logic will add overhead. Profile yourApu.tickfunction if you encounter performance bottlenecks. - Synchronization: The most critical aspect. The
CpuCyclesPerSamplecalculation andPendingCpuCyclesaccumulator are vital for smoothly bridging the CPU’s irregular cycle counts with the fixed audio sample rate. Incorrect synchronization leads to crackling, popping, or incorrect pitch. Aim for cycle-accurate APU updates to match the Game Boy’s behavior. - Buffer Management: The
SampleBufferandSDL_QueueAudioimplicitly manage buffers. Ensuring a steady stream of samples without underruns (starving the audio device) or overruns (too much data, leading to latency) is key. TheAudioBufferSizeforSDL_OpenAudioDeviceand themaxQueueByteslogic are important here. Too small a buffer can lead to underruns, too large can lead to noticeable audio latency. - Accuracy vs. Simplicity: We’ve made significant simplifications (no full frame sequencer, basic mixing). A production-quality emulator would require cycle-accurate implementation of all APU components, including sweep, envelope, length counters, and complex mixing logic. This iterative approach allows us to get basic functionality working before tackling the more complex, nuanced behaviors.
Common Issues & Solutions
- Issue: No sound output at all.
- Solution: Verify SDL initialization for audio, check
SDL_OpenAudioDevicereturn value. EnsureAPU.Enabled(NR52 bit 7) is set to 1 by the game, and individual channel enable bits (NRx2 bit 7, NRx4 bit 7) are also set. Log theSampleBuffer.CountinApuStateto confirm samples are being generated. Check ifSDL_QueueAudiois reporting errors.
- Solution: Verify SDL initialization for audio, check
- Issue: Sound is choppy, distorted, or has clicks/pops.
- Solution: This is almost always a timing or buffering issue. Ensure your
AudioFrequencyandAudioBufferSizeare reasonable (e.g., 44100 Hz, 2048 samples). Verify thatSDL_QueueAudiois being called consistently and that the amount of data queued is appropriate for the audio device’s buffer. IfSDL_GetQueuedAudioSizereports too little data, you’re underrunning. Increase yourPendingCpuCyclesthreshold before queuing, or process more CPU cycles per audio tick. Ensurefloattoint16conversion handles clipping correctly (* 32767.0is good for full range).
- Solution: This is almost always a timing or buffering issue. Ensure your
- Issue: Pitch is off or sounds static.
- Solution: Check your
FrequencyandTimercalculations inApu.fs. Consult Pan Docs for the exact formulas. A common mistake is incorrect division or multiplication factors. If sounds are static (no volume changes, no pitch slides), it means the length counter, envelope, and sweep units are not being emulated correctly (which is expected with our current basic implementation, but good to identify). This indicates a need for more advanced APU frame sequencer logic.
- Solution: Check your
๐ง Check Your Understanding
- What are the primary challenges when synchronizing the Game Boy’s APU with its CPU clock for accurate sound output?
- Explain the purpose of the
NRx4register’s trigger bit (bit 7) and why it’s crucial for sound programming. - How does the functional approach in F# (using immutable records and functions returning new state) help manage the complexity of APU state updates?
โก Mini Task
- Add logging to your
Apu.fswriteBytefunction to print the register address and value being written. Observe these logs when running a game like Tetris to see how the APU registers are manipulated by the game. Focus on0xFF14and0xFF19to see trigger events.
๐ Scenario
You’re running a Game Boy ROM in your emulator, and the music sounds correct in pitch, but it never fades out or changes volume dynamically. It just plays at a constant loudness until the song changes or the channel is explicitly turned off. What specific components of the Game Boy APU are most likely missing or simplified in your current emulator implementation? How would you begin to diagnose and address this in the code?
๐ TL;DR
- The Game Boy APU has four sound channels; we started with two square wave channels.
- APU state is modeled with F# records, and updated via CPU writes to I/O registers, with state propagated through
Cpu.executeInstructionandMmu.writeByte. SDL_QueueAudiois used for direct, streaming output of generated audio samples, preferred for its control over raw sample data.- Synchronization between CPU cycles and audio sample rate is critical for smooth sound.
๐ง Core Flow
- CPU executes instructions, potentially writing to APU registers, returning updated
CpuState,MmuState, andApuState. - MMU routes register writes to the
Apu.writeBytefunction, updatingApuState. Emulator.stepcallsApu.tick, advancing APU’s internal frequency timers and accumulating audio samples based on consumed CPU cycles.- Accumulated samples are converted to
int16and queued to the SDL audio device for playback, managing buffer levels to prevent glitches.
๐ Key Takeaway
Emulating sound requires not only understanding hardware registers but also mastering the delicate balance of real-time synchronization and buffering to translate discrete, cycle-accurate updates into a continuous, smooth audio stream. This initial implementation provides basic sound, but true fidelity demands deeper emulation of the APU’s dynamic components.
References
- F# Language Reference: https://learn.microsoft.com/en-us/dotnet/fsharp/
- .NET SDK Download: https://dotnet.microsoft.com/download
- Pan Docs - Sound Controller: https://gbdev.io/pandocs/Sound_Controller.html
- SDL Documentation: https://wiki.libsdl.org/
- SDL2-CS (VelcroPhysics.SDL.Net) GitHub: https://github.com/flibitijibibo/SDL2-CS
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.