The MOD file format is a staple of the 1990s demoscene. It's a simple low-level format, and was originally very closely tied to the hardware architecture of the Commodore Amiga. Back in the 1990s, I did write some demos, but they were never very successful, and now their source code is unfortunately lost forever. One thing I never really understood back then was how to write my own MOD player, so I always used code from other coders, published on BBSes. But now, thirty years later, I decided to write a MOD player in JavaScript, and learn about the format along the way.
Finding documentation
To the best of my knowledge, there is no official MOD file format specification. There are a few unofficial specifications, and they vary a lot in level of detail and clarity. Some were written in the 1990s, often by people who reverse-engineered the format from existing MOD players. A few of those contain a pretty juvenile language, so I assume they were written by teenagers at the time.
These are the resources that have been the most useful to me:
- Excerpt from a mail conversation, citing a text written by Lars "ZAP" Hamre, who created the original ProTracker music editor.
- A collection of sources probably collected in 1993
- A text from the late 1990s or early 2000s describing the MOD format from a PC programmer's perspective
Loading bytes
The first thing we need to do is to load the MOD file into memory. I'll use the Fetch API to do that and pass the resulting ArrayBuffer
to the constructor of a Mod
class, that will do the actual parsing.
// Import the Mod class
import { Mod } from './mod.js';
// Load MOD file from a url
export const loadMod = async (url) => {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const mod = new Mod(arrayBuffer);
return mod;
};
class Instrument {
constructor(modfile, index, sampleStart) {
// Instrument data starts at index 20, and each instrument is 30 bytes long
const data = new Uint8Array(modfile, 20 + index * 30, 30);
// Trim trailing null bytes
const nameBytes = data.slice(0, 21).filter(a => !!a);
this.name = String.fromCodePoint(...nameBytes).trim();
this.length = 2 * (data[22] * 256 + data[23]);
this.finetune = data[24] & 0x0f; // Signed 4 bit integer
if (this.finetune > 7) this.finetune -= 16;
this.volume = data[25];
this.repeatOffset = 2 * (data[26] * 256 + data[27]);
this.repeatLength = 2 * (data[28] * 256 + data[29]);
this.bytes = new Int8Array(modfile, sampleStart, this.length);
}
}
export class Mod {
constructor(modfile) {
// Store the pattern table
this.patternTable = new Uint8Array(modfile, 952, 128);
// Find the highest pattern number
const maxPatternIndex = Math.max(...this.patternTable);
// Extract all instruments
this.instruments = [];
let sampleStart = 1084 + (maxPatternIndex + 1) * 1024;
for (let i = 0; i < 31; ++i) {
const instr = new Instrument(modfile, i, sampleStart);
this.instruments.push(instr);
sampleStart += instr.length;
}
}
}
Playing a sample
Now that we have the MOD file loaded, I can start playing the samples from it. First, I have to extend the player worklet so that it can receive an array of signed bytes (an Int8Array) and play them in a reasonable speed.
class PlayerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = this.onmessage.bind(this);
this.sample = null;
this.index = 0;
}
onmessage(e) {
if (e.data.type === 'play') {
// Start at the beginning of the sample
this.sample = e.data.sample;
this.index = 0;
}
}
process(inputs, outputs) {
const output = outputs[0];
const channel = output[0];
for (let i = 0; i < channel.length; ++i) {
if (this.sample) {
// Using a bitwise OR ZERO forces the index to be an integer
channel[i] = this.sample[this.index | 0];
// Increment the index with 0.32 for a
// sample rate of 15360 or 14112 Hz, depending
// on the playback rate (48000 or 44100 Hz)
this.index += 0.32;
// Stop playing when reaching the end of the sample
if (this.index >= this.sample.length) {
this.sample = null;
}
} else {
channel[i] = 0;
}
}
return true;
}
}
registerProcessor('player-worklet', PlayerWorklet);
Finally, I will add a keydown
event listener to let the user play the samples by pressing keys on the keyboard.
import { loadMod } from './loader.js';
// Create the audio context
const audio = new AudioContext();
// Load an audio worklet
await audio.audioWorklet.addModule('player-worklet.js');
// Create a player
const player = new AudioWorkletNode(audio, 'player-worklet');
// Connect the player to the audio context
player.connect(audio.destination);
// Load Elekfunk from api.modarchive.org
const url = 'https://api.modarchive.org/downloads.php?moduleid=41529';
const mod = await loadMod(url);
// Keyboard map for the 31 instruments
const keyMap = '1234567890qwertyuiopasdfghjklzx';
// Play a sample when the user clicks
window.addEventListener('keydown', (e) => {
const instrIndex = keyMap.indexOf(e.key);
if (instrIndex === -1) return;
const instrument = mod.instruments[instrIndex];
console.log(instrument);
audio.resume();
player.port.postMessage({
type: 'play',
sample: instrument.bytes
});
});
Conclusion
Now it's possible to play the individual samples of a MOD file by pressing the corresponding keys on the keyboard. Next step is to parse the patterns, play a single pattern, and finally play a whole song. After that, I will dive into the details of note effects and try to get as many of them working correctly as possible. My goal is to be able to play the music from Arte by Sanity and Enigma by Phenomena properly.
You can try this solution at atornblad.github.io/js-mod-player.
The latest version of the code is always available in the GitHub repository.
Top comments (0)