How Lighthouse Emulates the Nintendo 64 Audio System with libultralib
Lighthouse ports the original Nintendo 64 sound microcode to the PC platform by wrapping the libultra audio library—commonly called libultralib—in a thin abstraction layer that preserves the exact behavior of Banjo-Kazooie’s original audio code while outputting to modern audio APIs.
The N64's audio pipeline was famously complex: the RSP (Reality Signal Processor) ran proprietary microcode to synthesize instruments, apply effects, and mix PCM samples, while the CPU submitted fresh command lists every frame. Lighthouse re-creates this entire flow by shipping a complete copy of the N64 SDK audio implementation in lib/ultralib/audio/ and bridging it to host OS primitives. This approach lets the original game code call n_alAudioFrame(), alSeqPlayer*(), and other libultralib functions without modification.
Core Components of N64 Audio Emulation in Lighthouse
Audio Manager: The CPU-Side Controller
The audio manager in src/core1/audio_manager.c initializes the heap, spawns the audio thread, and drives the per-frame synthesis loop. It allocates a fixed-size audio heap, configures the sequence player, and repeatedly invokes n_alAudioFrame() to generate sound buffers.
/* src/core1/audio_manager.c */
void audioManager_init(void) {
sALHeapBuffer = (u8 *)bk_malloc(AUDIO_HEAP_SIZE);
bzero(sALHeapBuffer, AUDIO_HEAP_SIZE);
alHeapInit(&sALHeapInfo, sALHeapBuffer, AUDIO_HEAP_SIZE);
/* Set video clock for PAL/NTSC timing */
#if VERSION == VERSION_USA_1_0
if (osTvType != OS_TV_NTSC) osViClock = VI_MPAL_CLOCK;
#elif VERSION == VERSION_PAL
osViClock = VI_PAL_CLOCK;
#endif
audioManager_create(); /* allocate queues, DMA, ACMD buffers */
sfxInstruments_init(); /* load SFX instrument tables */
musicInstruments_init(); /* load music instrument tables */
audioManager_startThread(); /* spawn the audio thread */
}
Key responsibilities of audio_manager.c:
- Calls
n_alInit(&sn_alGlobals, &sn_alConfig)to initialize the synthesizer - Registers custom oscillator callbacks via
audioManager_setupSeqp() - Spawns
audioManagerThread_entry()which blocks onaudioFrameMsgQ - Handles frame messages and executes
n_alAudioFrame()to produce mixed audio
libultralib: The RSP Microcode in Software
The lib/ultralib/audio/ directory contains a full software implementation of the N64's audio microcode. Files like load.c, synthesizer.c, and reverb.c implement the concrete n_al* functions that the original game expects.
| File | Purpose |
|---|---|
lib/ultralib/audio/load.c |
Instrument and sample loading |
lib/ultralib/audio/synthesizer.c |
Voice mixing, envelope generation, pitch shifting |
lib/ultralib/audio/reverb.c |
Reverb, chorus, and low-pass filter effects |
lib/ultralib/audio/seqplayer.c |
MIDI sequence playback and control |
These files provide drop-in replacements for the N64 SDK's audio functions, preserving exact reproduction of instruments, envelopes, and spatial effects.
Audio Interface: Bridging to PC Audio APIs
src/port/Audio/AudioInterface.c mimics the N64's Audio Interface (AI) hardware—specifically its DMA registers and timing—while actually feeding data to SDL or PortAudio. The game code still calls osAiSetFrequency(22000) and osAiGetLength(), but these resolve to PC-compatible implementations that schedule real audio callbacks.
OS Shim: Message Queues and Threading
The audio code relies on N64 OS primitives: osCreateMesgQueue, osRecvMesg, osCreateThread, osSetTimer. src/port/OS/OS.c re-implements these atop standard threading APIs, allowing the original synchronization patterns to function unchanged.
How the Frame Loop Mirrors N64 Hardware
Lighthouse's audio flow precisely duplicates the N64's frame-by-frame command submission:
-
Graphics thread builds the command list—In
src/core1/graphics_thread.c(lines 323-340), every other frame triggers audio task creation:/* src/core1/graphics_thread.c (excerpt) */ static s32 audiotimer_trigger = 0; audiotimer_trigger++; if (!(audiotimer_trigger & 1)) { struct ucode_task_data_s *active_audio_task; active_audio_task = sAudioTaskDataList[sActiveAudioTaskDataID]; thread5_startAudioTask(active_audio_task); osSendMesg(sActiveAudioTaskDataPtr->audio_mesg_queue, sActiveAudioTaskDataPtr->audio_mesg, 0); } -
Audio thread wakes and executes—
audioManager_handleFrameMsg()receives the message, locks audio state, and calls the synthesizer:/* src/core1/audio_manager.c */ bool audioManager_handleFrameMsg(AudioInfo *info, AudioInfo *prev_info) { s16 *outbuffer; Acmd *command_list_end; s32 command_list_len; port_lockAudio(); outbuffer = (s16 *)osVirtualToPhysical(info->data); command_list_end = n_alAudioFrame( audioManager.ACMDList[sCmdBufferIndex], &command_list_len, outbuffer, info->frame_samples); core1_15B30_addAudioTaskData(audioManager.ACMDList[sCmdBufferIndex], command_list_end, &audioManager.audioReplyMsgQ, OS_MESG_PTR(&info->reply_mesg_data)); return true; } -
libultralib processes the command list—
n_alAudioFrame()walks theAcmdlist, mixes active voices, applies effects (reverb, chorus, filtering), and writes finished PCM toinfo->data. -
PC backend plays the buffer—The audio interface consumes samples at the configured sample rate and outputs through the host device.
This mirrors the original: CPU builds Acmd → RSP processes microcode → AI DMAs to DAC. Lighthouse simply replaces RSP/AI with software implementations.
Using the libultralib Sequence Player
Game code interacts with music playback through the standard libultra API, all resolved to lib/ultralib/audio/:
/* Example from game code */
ALSeq *seq;
ALSeqPlayer *seqPlayer;
/* Load a sequence */
seq = alSeqNew(&sn_alGlobals, seqData, seqSize);
/* Create a sequence player and attach the sequence */
seqPlayer = alSeqPlayerNew(&sn_alGlobals, &sn_alConfig);
alSeqPlayerSetSeq(seqPlayer, seq);
/* Start playback */
alSeqPlayerPlay(seqPlayer);
The sn_alGlobals and sn_alConfig structures initialized in audio_manager.c configure polyphony limits, effect bus routing, and heap allocation—exactly as on N64 hardware.
Key Source Files for N64 Audio Emulation
| Path | Role |
|---|---|
src/core1/audio_manager.c |
Central audio initialization, thread management, frame handling |
src/core1/graphics_thread.c |
Audio task scheduling synchronized with graphics frames |
src/port/Audio/AudioInterface.c |
N64 AI register emulation for PC audio output |
src/port/OS/OS.c |
Message queues, threads, timers for audio synchronization |
lib/ultralib/audio/*.c |
Complete software synthesizer implementing original microcode |
lib/ultralib/audio/audio.h |
Public API headers exposing n_al* functions |
src/port/OS/libultra.c |
Glue mapping N64 SDK includes to local paths |
According to the HarbourMasters/Lighthouse source code, this architecture preserves byte-accurate audio behavior while eliminating the need for actual RSP emulation or binary blob injection.
Summary
- libultralib ports the N64 audio microcode—The
lib/ultralib/audio/directory contains a software reimplementation of the original RSP audio code, handling synthesis, effects, and sequencing. - Audio manager orchestrates the frame loop—
audio_manager.cinitializes the system, spawns the thread, and drivesn_alAudioFrame()calls that mirror RSP task execution. - Graphics thread triggers audio work—Every frame,
graphics_thread.cenqueues an audio task and signals the audio thread viaaudioFrameMsgQ, duplicating N64 CPU-to-RSP communication. - PC shims replace hardware—
AudioInterface.candOS.ctranslate N64 registers and OS primitives to portable APIs without changing game code. - Original sequence code runs unmodified—Game code calling
alSeqPlayerNew(),alSeqPlayerPlay(), and related functions executes exactly as on hardware.
Frequently Asked Questions
What sample rate does Lighthouse use for N64 audio emulation?
Lighthouse defaults to 22050 Hz (the original N64 rate) via osAiSetFrequency(22000), though the exact implementation in AudioInterface.c may resample to match host device capabilities while preserving the original timing characteristics.
Does Lighthouse use the original Banjo-Kazooie audio binaries?
No—Lighthouse uses reconstructed source code that calls the standard libultra audio API. The actual synthesizer implementation lives in lib/ultralib/audio/, which is a clean-room reimplementation of the N64 SDK audio library, not extracted microcode.
How does the audio thread stay synchronized with the game?
The audio thread blocks on osRecvMesg(&audioFrameMsgQ, ...) in audioManagerThread_entry(). The graphics thread sends one message per frame (or every other frame, depending on audiotimer_trigger), ensuring audio generation stays locked to video refresh—just as the N64's RSP audio tasks were triggered by vertical blank.
Can custom instrument definitions be loaded?
Yes. audioManager_setupSeqp() registers custom oscillator callbacks (initOsc, updateOsc, stopOsc) that the game's music system uses. The instrument tables loaded in sfxInstruments_init() and musicInstruments_init() define the actual sample maps and envelope parameters.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →