Sunday, July 10, 2016

Simple Amiga MOD File Player: Basic Playback

The MOD format maps very closely to the Amiga hardware. For example, the note pitch is specified as a period that can go directly into the AUDnPER hardware registers. I think probably the simplest way to make a relatively accurate MOD player is to structure the player similarly to the hardware, and then much of the logic can be the same as it would be on an Amiga.

Amiga MOD files are structured as patterns of rows, where each row has data for four channels. The channels map 1:1 to the Amiga's 4 digital-to-analog converter (DAC) channels. The player uses a timer interrupt to wake up periodically (once per tick), see if it's time to move to the next row, and if so, update the DAC settings for the new notes. The most convenient timing source is the 50-Hz vertical blanking interrupt (it would be 60 Hz in North America, but MODs seem to be designed around European timings). This is fired once per video frame and has a fixed frequency. However, there's also a programmable timer on the CIA (complex interface adapter) chip that can be used to get an more flexible interrupt rate.

Most music is around 100 beats per minute or 1.67 Hz, and simple music doesn't use notes shorter than 16th notes, which come 4 per beat. This means we might want our rows to play at 6.7 Hz or so. MOD players running from a faster interrupt like the 50-Hz vertical blanking interrupt slow things down with a speed parameter that specifies how many ticks to play each row for. This is typically something like 6, which would give a tempo of

60 seconds per minute * 50 ticks per second / 6 ticks per row / 4 rows per beat 
  = 125 beats per minute

Since each row uses multiple ticks, there are many effects that perform some action on each tick, for example modifying the sample period or volume.

There's also a tempo parameter that adjusts the tick rate such that tempo / tick rate = 2.5. This is accomplished by varying the interrupt rate from 50 Hz.

Note frequencies are specified with a period value, varying from 113 to 856. It may be possible to use other values, but I'll stick with this range. This is the length of time to play each value from an instrument sample, measured in ticks of a 3.546895-MHz oscillator (this is half the CPU clock rate, and not coincidentally 0.8 * the PAL color subcarrier frequency).

To adapt this to a Mac, we just need a way to call a routine once per tick to set the new sample address, period, and volume. The easiest thing to do is to put this into our audio callback. If there are N audio samples per tick, we can call the routine after we produce each N samples.

Here is a version of playmod.c that can finally start to play MOD files. This doesn't handle any effects, including setting volume or speed, so most MOD files will sound pretty bad. But it is a step in the right direction.

/* playmod.c */
/* Command-line Amiga MOD player */
/* No effects implemented yet */

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

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

#include "mod.h"

#define BUFFER_SIZE 16384

// Each channel automatically loops when it reaches the end of the
// current sample, using the next* values. To make a sample that
// doesn't loop, set the first two values to 0 and use a loop length
// of 2.
struct channel {
  int8_t *curSample;  // currently playing sample
  int8_t *nextSample; // sample/loop start to play next
  float offset;       // current offset within sample
  int length;         // length of currently playing sample
  int nextLength;     // length of next sample
  int volume;         // current volume
  int period;         // current period
};

struct audiostate {
  struct modfile *song;
  struct channel channels[4];
  float sample; // current sample in this tick
  int tick;     // current tick in this row
  int row;      // current row in this pattern
  int pattern;  // current pattern in the pattern table
  int speed;    // ticks per row
  float samplesPerTick;
};

// This flag is set by callback() when the song ends
volatile int songover = 0;

void callback(void *userData, AudioQueueRef inAQ, AudioQueueBufferRef inBuffer)
{
  struct audiostate *s = (struct audiostate *) userData;
  float *out = (float *) inBuffer->mAudioData;
  memset(out, 0, BUFFER_SIZE);

  if (songover) return;

  // loop over each sample
  for (int i = 0; i < BUFFER_SIZE/8; i++, s->sample += 1.0) {

    // do per-tick processing
    if (s->sample >= s->samplesPerTick) {
      s->sample -= s->samplesPerTick;

      s->tick++;
      if (s->tick >= s->speed) {
        s->tick = 0;
        s->row++;
        if (s->row >= 64) {
          s->row = 0;
          s->pattern++;
          if (s->pattern >= s->song->header.numpatterns) {
            songover = 1;
            break;
          }
        }
      }

      if (s->tick == 0) {
        // per-row channel updates
        int pattern = s->song->header.patternTable[s->pattern];
        struct note *n = &s->song->patterns[pattern].note[s->row][0];

        for (int c = 0; c < 4; c++) {
          int period = (n[c].periodHi << 8) | n[c].periodLo;
          if (period) {
            s->channels[c].period = period;
            s->channels[c].offset = 0; // restrike note
          }
          int sample = (n[c].sampleHi << 4) | n[c].sampleLo;
          if (sample) {
            s->channels[c].curSample = s->song->sampleData[sample-1];
            s->channels[c].nextSample = s->song->sampleData[sample-1];
            s->channels[c].length = s->song->header.samples[sample-1].len * 2;
            int repeat_start = s->song->header.samples[sample-1].repstart * 2;
            int repeat_length = s->song->header.samples[sample-1].replen * 2;
            s->channels[c].nextSample += repeat_start;
            s->channels[c].nextLength = repeat_length;
            s->channels[c].volume = s->song->header.samples[sample-1].volume;
            s->channels[c].offset = 0; // restrike note
          }
          int effect = (n[c].effectHi << 8) | n[c].effectLo;
          if (effect) {
            // FIXME: process effects here
          }
        }
      }
    }

    // mix samples and handle loops
    for (int c = 0; c < 4; c++) {
      struct channel *chan = &s->channels[c];

      if (chan->curSample == NULL) continue;

      int offset = trunc(chan->offset);

      // loop sample until offset is less than length
      if (offset >= chan->length) {
        chan->offset -= chan->length;
        offset = trunc(chan->offset);

        chan->curSample = chan->nextSample;
        chan->length = chan->nextLength;
      }

      float v = (float) (chan->curSample[offset] * chan->volume) / 64.0;

      if (c == 1 || c == 2) out[i*2] += v;
      else out[i*2 + 1] += v;

      float increment = 3.546895e6 / 44100.0 / chan->period;
      chan->offset += increment;
    }

    // normalize to +/- 1.0 range
    out[i*2] /= 2.0 * 128.0;
    out[i*2 + 1] /= 2.0 * 128.0;
  }
  OSStatus err = AudioQueueEnqueueBuffer(inAQ, inBuffer, 0, NULL);
  if (err != noErr) songover = 1;
}

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 playMod(struct modfile *mod)
{
  struct audiostate audiostate;

  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,
   };

  memset(&audiostate, 0, sizeof(audiostate));
  audiostate.song = mod;
  audiostate.tick = -1;
  audiostate.speed = 6;
  audiostate.samplesPerTick = 44100 / 50;
  audiostate.sample = audiostate.samplesPerTick;

  songover = 0;

  err = AudioQueueNewOutput(&format, callback, &audiostate, 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(&audiostate, aq, buf[i]);
  }

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

  while(!songover) {
    sleep(1);
  }

  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;
}

int main(int argc, char *argv[])
{
  struct modfile *mod = NULL;
  if (argc < 2) {
    fprintf(stderr, "usage: playmod modfile.mod\n");
    return -1;
  }

  mod = loadMod(argv[1]);
  if (mod == NULL) {
    fprintf(stderr, "Could not load MOD file\n");
    return -1;
  }

  playMod(mod);

  freeMod(mod);

  return 0;
}

This could obviously stand to be optimized, but it's using only 2% of my CPU at the moment, so I'll defer optimization until later. One idea would be to convert the MOD file in memory from its native format to something that doesn't require so much work at runtime.

Next will be implementing some of the effects.

References:

No comments:

Post a Comment