Nintendo 64 console with EverDrive cartridge

Nintendo 64 Part 13: Basic Audio

, Nintendo 64, Programming

I want to get all the basic infrastructure in place first before developing the game, because I think that adding features like audio to a game later in development can cause some problems. So, it’s time to get audio working.

Detour: Building and Running

There are two important scripts that I’ve written for development.

One is called artifact, and it build the game, puts it in an archive, and names the archive with the revision number and build date. This way, I have a directory full of old builds that I can go back to. The artifact script refuses to run if the Git repository has unsaved changes.

$ bazel run //tools/artifact

The other script is called run, and it, quite simply, runs the game, but it takes several options.

If I just want to build the latest version of the game and run it in my default emulater (CEN64):

$ ./run.py

To run the game on real hardware, I can boot the Nintendo 64 to the EverDrive menu and pass the -hardware flag:

$ ./run.py -hardware

If I want to run an old version that I saved wiht the artifact script, I can specify the revision:

$ ./run.py -revision 55

I can also build and run a different target besides the game. I’m using a target named //experimental/audio to test audio code, and I can run it like this:

$ ./run.py -target //experimental/audio

Audio Buffers

The Nintendo 64 audio interface has a FIFO containing two pointers to buffers in RAM. The buffers contain 16-bit signed samples with interleaved stereo. All we have to do in order to avoid underruns is to put a second buffer in the queue before the first buffer runs out.

Main Thread

To get it working, I’m just going to initialize three buffers with sawtooth waves. Don’t forget to flush the cache so it gets written to RAM.

static int16_t audio_buffers[3][4 * 1024]
    __attribute__((aligned(16), section("uninit")));

for (int i = 0; i < 3; i++) {
  unsigned phase = 0;
  unsigned rate = ((50 * (i + 4)) << 16) / 22050;
  for (unsigned j = 0; j < ARRAY_COUNT(audio_buffers[0]) / 2; j++) {
    audio_buffers[i][j * 2] = phase;
    audio_buffers[i][j * 2 + 1] = phase;
    phase += rate;
  }
}
osWritebackDCache(audio_buffers, sizeof(audio_buffers));

osAiSetFrequency(22050);

I extend the scheduler interface so it can accept tasks that contain audio buffers, and send the audio buffers to the scheduler in a loop. This may not need the scheduler, but it will need the scheduler once it uses RSP tasks to create audio data. In the main loop, I add code that submits the next buffer:

bool audiobuffer_active[3] = {false, false, false};
int which_abuffer = 0;

// Main loop.
for (;;) {
  ...
  if (!audiobuffer_active[which_abuffer]) {
    struct scheduler_task *task = &audio_tasks[which_abuffer];
    task->flags = SCHEDULER_TASK_AUDIOBUFFER;
    task->data.audiobuffer = (struct scheduler_audiobuffer){
        .ptr = audio_buffers[which_abuffer],
        .size = sizeof(audio_buffers[0]),
        .done_queue = &message_queue,
        .done_mesg = make_event(EVENT_ABUFDONE, which_abuffer),
    };
    scheduler_submit(&scheduler, task);
    audiobuffer_active[which_abuffer] = true;
    which_abuffer++;
    if (which_abuffer == 3) {
      which_abuffer = 0;
    }
  }
  ...
}

When the main thread receives EVENT_ABUFDONE back from the scheduler, it marks the relevant buffer as available:

case EVENT_ABUFDONE:
  audiobuffer_active[index] = false;
  break;

Scheduler

The scheduler has to keep track of these buffers and submit them to the audio interface when possible. I add an array so the scheduler can keep track of three in-flight audio buffers and write a function audio_push to be called when the scheduler recieves an audio buffer:

unsigned count;
// Audiobuffers: buffers[0] and buffers[1] are in the DMA FIFO, and
// have been sent to osAiSetNextBuffer. buffers[2] is pending. The
// number of entries in this array that are valid is 'count'.
struct scheduler_audiobuffer buffers[3];

static void audio_push(struct scheduler_audiobuffer *buf) {
  if ((((uintptr_t)buf->ptr | buf->size) & 15) != 0) {
    fatal_error("Unaligned audio buffer\nptr=%p\nsize=%zu",
                buf->ptr, buf->size);
  }
  if (count > 2) {
    fatal_error("Audio buffer overflow");
  }
  if (count < 2) {
    int r = osAiSetNextBuffer(buf->ptr, buf->size);
    if (r != 0) {
      unsigned len = osAiGetLength();
      fatal_error("Audio device busy: %u", len);
    }
  }
  buffers[count] = *buf;
  count++;
}

Note that this is a bit different from the way that the scheduler handles framebuffers. With framebuffers, if there is only one framebuffer in the scheduler’s queue, it will be displayed on-screen indefinitely. The video interface will keep displaying the same buffer over and over. The audio system is different: once the audio interface gets to the end of a buffer, it’s done and the audio interface will just output silence if there are no more buffers available.

To get notified when the audio interface finishes, I register the scheduler to receive events from the audio system. This event will get sent whenever a buffer in the audio interface FIFO is consumed:

osSetEventMesg(OS_EVENT_AI, &sc->evt_queue, (OSMesg)EVT_AUDIO);

However, I can’t rely on this event alone to figure out when audio buffers are consumed, because I can’t be sure of the exact timing of audio events. From tests, it seems that there are circumstances where these events will get sent while the audio interface still has two buffers in its queue. To fix this, the handler for audio events explicitly checks the status of the audio interface.

  1. If the scheduler has three audio buffers in its queue, then the handler just tries to submit the third buffer to the audio interface, and returns if this fails.
  2. If the scheduler has two audio buffers, the handler queries the audio interface to see how much pendind data is in the audio interface queue. If this value is equal or smaller than the size of the second buffer, it means that the first buffer is done. Otherwise, the handler returns.
static void audio_pop(void) {
  if (count == 0) {
    return;
  }
  // On real hardware, it seems that there is some issue with the
  // ordering of the events. So we don't assume that the audio
  // device isn't busy just because this function was called.
  if (count > 2) {
    // Just try to push the next buffer, and fail otherwise.
    int r = osAiSetNextBuffer(buffers[2].ptr, buffers[2].size);
    if (r != 0) {
      return;
    }
  } else {
    unsigned len = osAiGetLength();
    if (len >= buffers[1].size) {
      return;
    }
  }
  if (buffers[0].done_queue != NULL) {
    int r = osSendMesg(buffers[0].done_queue, buffers[0].done_mesg,
                       OS_MESG_NOBLOCK);
    if (r != 0) {
      fatal_error("Dropped audio buffer message");
    }
  }
  buffers[0] = buffers[1];
  buffers[1] = buffers[2];
  buffers[2] = (struct scheduler_audiobuffer){0};
  count--;
}

Success!

The resulting program makes noise.

$ ./run.py -target //experimental/audio

It plays a full volume sawtooth major triad in a loop. Warning: turn your volume down!

Audio Demo rev 116 34.4 kB