Sunday, July 10, 2016

Simple Amiga MOD File Player: Preliminaries

I've always been fascinated by making music with computers. When I was in middle school, in the dark ages before I had internet access, I bought a book that was supposed to tell me how to program my Sound Blaster card, but its sample code didn't show you how to do anything other than play existing files from disk. The problem was that I didn't have any music files to play in the first place, so that didn't help at all.

The book wasn't all a waste, though. It came with some Amiga MOD music to listen to. This was intriguing, because up to this point I had only heard my computer do OPL3 FM synthesis, which sounds kind of embarrassing, while the Amiga MOD files sounded like real instruments.

Unfortunately, without the internet, I had to wait a few more years before I found another book that explained how to actually read MOD files and play them. And I never got around to finishing my player.

So now I'm going to finally write that MOD player I always wanted to write. First thing's first: set up the development environment on my Mac. For now, I'll do development on the command line with Emacs and Makefiles. I'll use the native Core Audio API for the audio interface so I can stay out of Objective-C.

First, I had to use

$ xcode-select --install
to get the command line compiler, header files in /usr/include, and git.

I already have emacs installed, from emacsformacosx.com.

Here's "Hello, World" just to make sure everything works. This is playmod.c:

/* playmod.c */
/* Test of basic compilation functionality. */

#include <stdio.h>

int main(int argc, char *argv[])
{
  printf("Hello, playmod\n");
  return 0;
}

Here is the Makefile to build this:

# Makefile for playmod
playmod: playmod.c Makefile
	cc $< -o $@

This builds with make, and the resulting executable works:

$ make
cc playmod.c -o playmod
$ ./playmod
Hello, playmod

The next step is playing sound. The relevant API is called AudioQueue, part of the AudioToolbox framework. This works like most other sound programming APIs. You provide a callback function to fill a buffer with new samples whenever the sound hardware needs more.

The only confusing part was bootstrapping the playback. Other APIs I've used will start calling your callback when you begin playback. AudioQueue seemed to require that I call AudioQueueEnqueueBuffer() on each new buffer to get things started. I did this by calling the callback function myself on each new buffer I allocated.

Here's a new version of playmod.c that still doesn't play MOD files, but just plays sine waves out of each speaker:

/* playmod.c */
/* Plays sine waves for 5 seconds. */

#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <unistd.h>

#include <AudioToolbox/AudioQueue.h>
#include <AudioToolbox/AudioFormat.h>

#define BUFFER_SIZE 16384

// Flag set if the callback fails so the main application can quit
OSStatus callback_error = noErr;

int callback_calls = 0;

void callback(void *inUserData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer)
{
  static float t = 0;
  OSStatus err;
  float *buf = (float *) inBuffer->mAudioData;
  callback_calls++;
  for (int i = 0; i < BUFFER_SIZE / 4; i+=2) {
    t += 1.0/44100.0;
    buf[i] = sin((float) 440 * 2 * M_PI * t);
    buf[i+1] = sin((float) 660 * 2 * M_PI * t);
  }

  err = AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL);
  if (err != noErr) callback_error = err;
}

const char *aq_error_string(OSStatus err)
{
  switch(err) {
  case kAudioQueueErr_InvalidBuffer: return "Invalid Buffer";
  case kAudioQueueErr_BufferEmpty: return "Buffer Empty";
  case kAudioQueueErr_DisposalPending: return "Disposal Pending";
  case kAudioQueueErr_InvalidProperty: return "Invalid Property";
  case kAudioQueueErr_InvalidPropertySize: return "Invalid Property Size";
  case kAudioQueueErr_InvalidParameter: return "Invalid Parameter";
  case kAudioQueueErr_CannotStart: return "Cannot Start";
  case kAudioQueueErr_InvalidDevice: return "Invalid Device";
  case kAudioQueueErr_BufferInQueue: return "Buffer In Queue";
  case kAudioQueueErr_InvalidRunState: return "Invalid Run State";
  case kAudioQueueErr_InvalidQueueType: return "Invalid Queue Type";
  case kAudioQueueErr_Permissions: return "Permissions";
  case kAudioQueueErr_InvalidPropertyValue: return "Invalid Property Value";
  case kAudioQueueErr_PrimeTimedOut: return "Prime Timed Out";
  case kAudioQueueErr_CodecNotFound: return "Codec Not Found";
  case kAudioQueueErr_InvalidCodecAccess: return "Invalid Codec Access";
  case kAudioQueueErr_QueueInvalidated: return "Queue Invalidated";
  case kAudioQueueErr_RecordUnderrun: return "Record Underrun";
  case kAudioQueueErr_EnqueueDuringReset: return "Enqueue During Reset";
  case kAudioQueueErr_InvalidOfflineMode: return "Invalid Offline Mode";
  case kAudioFormatUnsupportedDataFormatError: return "Unsupported Data Format Error";
  default: return "Unkown error";
  }
}

int main(int argc, char *argv[])
{
  OSStatus err;
  AudioQueueRef aq;
  AudioQueueBufferRef buf[2];
  AudioStreamBasicDescription format = {
   .mSampleRate = 44100.0,
   .mFormatID = kAudioFormatLinearPCM,
   .mFormatFlags = kLinearPCMFormatFlagIsFloat,
   .mBytesPerPacket = 8,
   .mFramesPerPacket = 1,
   .mBytesPerFrame = 8,
   .mChannelsPerFrame = 2,
   .mBitsPerChannel = 32,
   };
  err = AudioQueueNewOutput(&format, callback, NULL, NULL, NULL, 0, &aq);
  if (err != noErr) {
    printf("AudioQueueNewOutput Error %d: %s\n", err, aq_error_string(err));
    return -1;
  }

  for (int i = 0; i < sizeof(buf)/sizeof(buf[0]); i++) {
    err = AudioQueueAllocateBuffer(aq, BUFFER_SIZE, &buf[i]);
    if (err != noErr) {
      printf("AudioQueueAllocateBuffer Error %d: %s\n", err, aq_error_string(err));
      return -1;
    }
    buf[i]->mAudioDataByteSize = BUFFER_SIZE;
    callback(NULL, aq, buf[i]);
  }

  err = AudioQueueStart(aq, NULL);
  if (err != noErr) {
    printf("AudioQueueStart Error %d: %s\n", err, aq_error_string(err));
    return -1;
  }

  sleep(5);

  printf("Callback was called %d times\n", callback_calls);

  err = AudioQueueStop(aq, TRUE);
  if (err != noErr) {
    printf("AudioQueueStop Error %d: %s\n", err, aq_error_string(err));
    return -1;
  }

  err = AudioQueueDispose(aq, 0);
  if (err != noErr) {
    printf("AudioQueueDispose Error %d: %s\n", err, aq_error_string(err));
    return -1;
  }
  return 0;
}

The Makefile has to be modified to tell clang which frameworks are being used:

# Makefile for playmod
LDFLAGS = -framework CoreFoundation -framework AudioToolbox -F/Library/Frameworks -lm

playmod: playmod.c Makefile
	cc $(LDFLAGS) $< -o $@

This code passes NULL as the 4th argument to AudioQueueNewOutput() so that AudioQueue allocates a thread to run our callback for us. That allows us to use the normal Unix sleep() call to wait for playback instead of any extra complexity involving CFRunLoopRun().

Next time, I'll implement MOD file reading and writing.

No comments:

Post a Comment