Reproducing the Macintosh Boot Beep from JavaScript Cloud Code

Like many of us here at Parse, I’ve always had an interest in computer-generated audio. So when I first read Andy Hertzfeld’s account of how the original Macintosh “boot beep” was invented, I was fascinated. And since he provided the original Motorola 68000 assembly language source code that actually generated the beep, I knew it was only a matter of time until I would have to dissect it and get it working myself.

Recently, Stanley Wang launched a great new feature in Cloud Code. The new Buffer class from Node.js lets you modify binary data from within JavaScript. For his blog post, I created a Cloud Code function to generate a GIF file, just to give an example of one of the things you could do with a Buffer. Well, generating a small audio file is another.

As with most sound APIs, the Macintosh primarily provided a buffer filled with Linear PCM samples. Basically, the values in the buffer are “samples” arranged in chronological order. Each sample indicates the amplitude of the audio waveform at that point in time. After the audio in the buffer is finished playing, it starts over again at the beginning. By changing the contents of the buffer, different sounds can be played.

The simplest standard file format for PCM data is the WAVE file. WAVE files aren’t used as commonly as MP3s because they are uncompressed, and therefore can be quite large. However, the lack of compression makes them very easy to generate. So, for a modern implementation of the Macintosh Boot Beep, it makes sense to generate the PCM data and output it as a WAVE file, which can be played with most audio players. For creating an output file of arbitrary size, it’s helpful to create a wrapper around Buffer that will let you append data to it, and grow the buffer as necessary.

var _ = require('underscore');
var Buffer = require('buffer').Buffer;

/**
 * Lets you fill a Buffer without having to know its size beforehand.
 */
var BufferWriter = function() {
  this._size = 0;
  this._buffer = new Buffer(100);
};

_.extend(BufferWriter.prototype, {
  buffer: function() {
    return this._buffer.slice(0, this._size);
  },

  writeUInt8: function(value) {
    this._reserve(this._size + 1);
    this._buffer.writeUInt8(value, this._size);
    this._size = this._size + 1;
  },

  writeInt16LE: function(value) {
    this._reserve(this._size + 2);
    this._buffer.writeInt16LE(value, this._size);
    this._size = this._size + 2;
  },

  writeUInt16LE: function(value) {
    this._reserve(this._size + 2);
    this._buffer.writeUInt16LE(value, this._size);
    this._size = this._size + 2;
  },

  writeUInt32LE: function(value) {
    this._reserve(this._size + 4);
    this._buffer.writeUInt32LE(value, this._size);
    this._size = this._size + 4;
  },

  writeUTF8: function(str) {
    this._reserve(this._size + str.length * 6);
    this._size = this._size + this._buffer.write(str, this._size);
  },

  /**
   * Resizes the backing buffer to ensure it can hold at least size bytes.
   */
  _reserve: function(size) {
    var current = this._buffer.length;
    while (size >= current) {
      this._buffer = Buffer.concat([this._buffer, new Buffer(current)],
                                   current * 2);
      current = this._buffer.length;
    }
  }
});

Then, with a little knowledge of the WAVE format, it’s easy to create a class that will let you “record” a predetermined amount of audio into a file.

/**
 * Creates a helper to write a WAVE file to a Buffer.
 * @param {Number} seconds - The length of the WAVE file to write.
 * @param {Number} sampleRate - The sample rate in Hz, such as 44100 for a CD.
 * @param {Number} bitsPerSample - Either 8 or 16.
 */
var WaveWriter = function(seconds, sampleRate, bitsPerSample) {
  this._sampleRate = sampleRate;
  this._bitsPerSample = bitsPerSample;
  this._writer = new BufferWriter();
  this._samples = Math.round(this._sampleRate * seconds);

  var numChannels = 1;
  var subChunk2Size = this._samples * numChannels * bitsPerSample / 8;
  var chunkSize = 36 + subChunk2Size;
  try {
    this._writer.writeUTF8("RIFF");
    this._writer.writeUInt32LE(chunkSize);
    this._writer.writeUTF8("WAVE");
  } catch (e1) {
    console.error("Got an exception while writing WAVE header.");
    throw e1;
  }

  try {
    // Sub-chunk 1
    var byteRate = 36 + sampleRate * numChannels * bitsPerSample / 8;
    var blockAlign = numChannels * bitsPerSample / 8;

    this._writer.writeUTF8("fmt ");
    this._writer.writeUInt32LE(16);
    this._writer.writeUInt16LE(1);
    this._writer.writeUInt16LE(numChannels);
    this._writer.writeUInt32LE(sampleRate);
    this._writer.writeUInt32LE(byteRate);
    this._writer.writeUInt16LE(blockAlign);
    this._writer.writeUInt16LE(bitsPerSample);
  } catch (e2) {
    console.error("Got an exception while writing subchunk 1.");
    throw e2;
  }

  try {
    // Sub-chunk 2
    this._writer.writeUTF8("data");
    this._writer.writeUInt32LE(subChunk2Size);
  } catch (e3) {
    console.error("Got an exception while writing subchunk 2.");
    throw e3;
  }
};

_.extend(WaveWriter.prototype, {
  /**
   * Write a single sample to the WAVE file.
   * @param {Number} sample The amplitude of the sample, from -1 to 1.
   * @return {Boolean} false if the file is already full, true otherwise.
   */
  writeSample: function(sample) {
    if (this._samples === 0) {
      return false;
    }
    // Clamp values out of range.
    if (sample < -1.0) {
      sample = -1;
    }
    if (sample > 1.0) {
      sample = 1;
    }
    if (this._bitsPerSample === 16) {
      this._writer.writeInt16(Math.floor(32767 * sample));
    } else if (this._bitsPerSample === 8) {
      try {
        this._writer.writeUInt8(Math.floor(-127.5 * sample + 127.5));
      } catch (e) {
        console.error("Got an exception while writing a sample.");
        console.error("Sample is " + Math.floor(-127.5 * sample + 127.5));
        throw e;
      }
    }
    this._samples = this._samples - 1;
    return true;
  },

  /**
   * Fills the rest of the file with zero-amplitude samples.
   */
  close: function() {
    while (this._samples > 0) {
      this.writeSample(0.0);
    }
  },

  /**
   * Returns the underlying buffer with the contents of the WAVE file.
   */
  buffer: function() {
    return this._writer.buffer();
  },
});

Now comes the fun part. It’s time to translate the original boot beep code into JavaScript. This might strike you as a crazy thing to do. It probably is, but don’t let that stop you. To achieve maximum accuracy, I translated the original assembly language instructions directly into JavaScript. The hardest part of a direct translation is that JavaScript lacks any goto construct. Fortunately, all of the branching done in the original code was pretty simple, and translated nicely into _.times or _.each. It’s possible that evaluating _.times and passing it a function object might even be faster running in V8 in Chrome on a MacBook Pro than a single DBRA instruction was on the original Macintosh, although that’s purely conjecture. Compare to the original source.

/**
 * Adapted from http://folklore.org/projects/Macintosh/more/BootBeep.txt
 * A direct translation of the m68k asm into JavaScript.
 * @returns {Buffer} a Buffer which  corresponds to the audio buffer on the
 * original Macintosh, repeated for the duration of the boot beep. The Buffer
 * contains a series of words, where the lower byte of each word is the
 * amplitude of the audio signal at this point.
 */
var bootbeepASM = function() {
  d3 = 40;  // D3 contains the duration: 40 is boot beep

  var output = new Buffer(0);
  var buffer = new Buffer(2 * 5 * 4 * 19);

  var a0 = 0;  // get sound buffer address
  var a1 = 0;

  console.log("Initializing buffer.");

  // repeat 74 byte sequence 5 times
  _.times(5, function() {
    // 4 byte table to fill buffer with
    _.each([0x06, 0xC0, 0x40, 0xFA], function(d1) {
      _.times(19, function() {         // repeat 19 times
        buffer.writeUInt16LE(d1, a0);  // move in a byte
        a0 += 2;                       // bump to next buffer location
      });
    });
  });

  console.log("Filtering buffer " + d3 + " times.");

  // OK, now filter it for a nice fade -- repeat the filtering process D3 times
  _.times(d3, function() {
    a0 = 0;         // point A0 to start of buffer
    a1 = a0 + 146;  // point A1 74 values in
    // process 74 samples
    _.times(74, function() {
      var d2 = buffer.readUInt8(a1);  // get 74th value
      a1 += 4;                        // bump to 76th value
      var d1 = buffer.readUInt8(a1);  // make it a word value (lol, no)
      d2 += d1;                       // add to the accumulator
      a1 -= 2;                        // point at 75th value
      d1 = buffer.readUInt8(a1);      // get it
      d2 += d1;                       // add to the accumulator
      d2 += d1;                       // count it twice

      d2 = Math.round(d2 / 4);        // divide it by 4
      buffer.writeUInt8(d2, a0);      // store it away
      a0 += 2;                        // bump to next value
    });

    // 296 values to copy
    _.times(296, function() {
      buffer.writeUInt8(buffer.readUInt8(a0 - 74), a0);
      a0 += 2;
    });

    // Only the first 370 words is actually used by the Mac audio buffer.
    output = Buffer.concat([output, buffer.slice(0, 2 * 370)]);
  });

  return output;
};

You’ll notice the JavaScript version skips over the part where it should “wait for blanking”. What is that about? Well, if you search the web for the details of the original Mac’s audio hardware, you’ll see that the 370 sample audio buffer gets flushed every time the screen refreshes! This is important, because it tells us the “sample rate” — the rate at which the samples should be output. The screen refreshes at 60.15 Hz, so the sample rate for our output WAVE file should be close to 370 * 60.15 = 22255.5 Hz, about half the quality of a CD. For compatability, let’s round this to 22050 Hz, exactly half CD quality.

Furthermore, the docs make it clear that each sample is a word, but only the (unsigned) high-order byte of the word is used for audio. In fact, the low-order bytes are used for disk IO! Ah, the perils of early computing. We forget how good we have it these days. See the warning:

Warning:  The low-order byte of each word in the sound buffer is used to
          control the speed of the motor in the disk drive. Don't store
          any information there, or you'll interfere with the disk I/O.

Knowing the sample rate, we can easily create a function to take the output of bootbeepASM and convert it into an actual WAVE file.

/**
 * Runs the bootbeepASM methods and then converts the audio data to a WAVE file.
 * @returns {Buffer} a Buffer containing the contents of a WAVE file.
 */
var bootbeep = function() {
  // 22050 Hz is an approximation of the Mac's 370 * 60.15 Hz = 22255.5 Hz.
  var writer = new WaveWriter(1.5, 22050, 8);
  var samples = bootbeepASM();
  console.log("Copying samples to WAVE.");
  var i = 0;
  var sample = 0;
  do {
    if ((i * 2) < samples.length) {
      sample = (samples.readUInt8(i * 2) / 128) - 1.0;
    }
    i++;
  } while (writer.writeSample(sample));
  writer.close();
  return writer.buffer();
};

Parse.Cloud.define("bootbeep", function(request, response) {
  response.success(bootbeep().toString("base64"));
});

At this point, you could use the same trick as with the GIF example to create a file on-the-fly and add it to the html of the page using an HTML5 <audio> tag with a “data:” url. I’ve pre-populated such a control below so you can hear the output of this function yourself.

To see how close the output is, just search for any video of a Macintosh or Macintosh Plus booting, and compare for yourself. Pretty close, right? Admittedly, this isn’t the most practical code example in the world, but it does show just how innovative you can get with a little JavaScript Cloud Code. And don’t forget, we’re always hiring, just in case you want to help us push these boundaries even further.

Bryan Klimt
February 27, 2013
blog comments powered by Disqus

Comments are closed.

Archives

Categories

RSS Feed Follow us Like us