Playing a full song, almost
This is part 3 of the JavaScript MOD Player series. If you haven't read the first part, here it is: Generating sound in modern Web Audio API
In the previous article, I described how to extract the raw sample data from a MOD file and how to play it. The next step is to get the song data out from the MOD file and into a more useful data structure.
According to the sources I found (see the previous article), the song data is stored using 1024 bytes per "pattern". A pattern is a sequence of 64 notes in 4 channels, each note being a 1/16 of a bar. Original MOD files contain four channels, which means that a total of four samples, or instruments, can be played at the same time. Each note is 4 bytes, each row contains 4 bytes, and each pattern contains 64 rows, which makes 1024 bytes per pattern.
Byte 0 | Byte 1 | Byte 2 | Byte 3 | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
I7 | I6 | I5 | I4 | P11 | P10 | P9 | P8 | P7 | P6 | P5 | P4 | P3 | P2 | P1 | P0 | I3 | I2 | I1 | I0 | E11 | E10 | E9 | E8 | E7 | E6 | E5 | E4 | E3 | E2 | E1 | E0 |
For each note, the instrument index, the period (explained later), and the effect (also explained later) are stored. The table above shows that the instrument index (I) is stored in the first 4 bits of byte 0 and the first 4 bits of byte 2. The period (P) is stored in the last 4 bits of byte 0 and all 8 bits of byte 1. The effect (E) is stored in the last 4 bits of byte 2 and all 8 bits of byte 3. Using this information, I can write three new classes to store the song data, Pattern
, Row
, and Note
.
class Note {
constructor(noteData) {
this.instrument = (noteData[0] & 0xf0) | (noteData[2] >> 4);
this.period = (noteData[0] & 0x0f) * 256 + noteData[1];
this.effect = (noteData[2] & 0x0f) * 256 + noteData[3];
}
}
class Row {
constructor(rowData) {
this.notes = [];
// Each note is 4 bytes
for (let i = 0; i < 16; i += 4) {
const noteData = rowData.slice(i, i + 4);
this.notes.push(new Note(noteData));
}
}
}
class Pattern {
constructor(modfile, index) {
// Each pattern is 1024 bytes long
const data = new Uint8Array(modfile, 1084 + index * 1024, 1024);
this.rows = [];
// Each pattern is made up of 64 rows
for (let i = 0; i < 64; ++i) {
const rowData = data.slice(i * 16, i * 16 + 16);
this.rows.push(new Row(rowData));
}
}
}
The constructor of the Mod
class must also be updated to read the song data, by calling the Pattern
constructor for each pattern.
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;
}
// Extract the pattern data
this.patterns = [];
for (let i = 0; i <= maxPatternIndex; ++i) {
const pattern = new Pattern(modfile, i);
this.patterns.push(pattern);
}
}
}
Playing one channel of one pattern
Now that the song data is stored in a more useful format, it is time to play it. The first step is to play one channel of one pattern. The playback is driven by the process
method of the worklet processor class. For each sample that is played, time moves forward by 1/48 000 or 1/44 100 of a second, depending on the sample rate of the audio context. Because of this, we can use the samples as a time source, and base all time calculations on the progression of samples.
The period
of a note is the number of clock pulses of the Amiga Paula co-processor, which run at half the speed of the main CPU clock. On a PAL Amiga, the main CPU clock frequency is 7 093 789.2 Hz, so the co-processor frequency is 3 546 894.6 Hz. A sample playing with period 214 (the period of the note C-3) will play at 3 546 894.6/214 = 16 574.3 Hz. For an audio context with a sample rate of 48 000 Hz, this means that each sample will be output 48 000/16 561.4 = 2.896 times before the next sample is output. These calculations must be done each time the period changes.
To step forward in the song, i also need to keep track of the current position in the song, the current row to play, and how many samples are left to play before moving to the next row. For now, the number of samples per row is calculated using the BPM of 125, and the sample rate of the audio context.
const PAULA_FREQUENCY = 3546894.6;
class PlayerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = this.onmessage.bind(this);
this.mod = null;
}
onmessage(e) {
if (e.data.type === 'play') {
this.mod = e.data.mod;
this.sampleRate = e.data.sampleRate;
this.bpm = 125;
this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4;
// Start at the last row of the pattern "before the first pattern"
this.position = -1;
this.rowIndex = 63;
// Immediately move to the first row of the first pattern
this.outputsUntilNextRow = 0;
this.instrument = null;
this.period = null;
}
}
nextRow() {
++this.rowIndex;
if (this.rowIndex == 64) {
this.rowIndex = 0;
++this.position;
}
const patternIndex = this.mod.patternTable[this.position];
const pattern = this.mod.patterns[patternIndex];
const row = pattern.rows[this.rowIndex];
const note = row.notes[0];
if (note.instrument) {
this.instrument = this.mod.instruments[note.instrument - 1];
this.sampleIndex = 0;
}
if (note.period) {
this.period = note.period - this.instrument.finetune;
}
}
nextOutput() {
if (!this.mod) return 0.0;
if (this.outputsUntilNextRow <= 0) {
this.nextRow();
this.outputsUntilNextRow += this.outputsPerRow;
}
this.outputsUntilNextRow--;
if (!this.instrument || !this.period) return 0.0;
if (!this.period) return 0.0;
if (this.sampleIndex >= this.instrument.bytes.length) {
return 0.0;
}
const sample = this.instrument.bytes[this.sampleIndex | 0];
const sampleRate = PAULA_FREQUENCY / this.period;
this.sampleIndex += sampleRate / this.sampleRate;
return sample / 256.0;
}
process(inputs, outputs) {
const output = outputs[0];
const channel = output[0];
for (let i = 0; i < channel.length; ++i) {
const value = this.nextOutput();
channel[i] = value;
}
return true;
}
}
registerProcessor('player-worklet', PlayerWorklet);
To finally play the first channel of the song, I need to send the complete mod data to the worklet, and not just one instrument. I also need to send the sample rate of the audio context, so that the worklet can calculate all timings correctly. Here are the changes to the main thread code:
// Load Elekfunk from api.modarchive.org
const url = 'https://api.modarchive.org/downloads.php?moduleid=41529';
const mod = await loadMod(url);
// Play a sample when the user clicks
window.addEventListener('click', () => {
audio.resume();
player.port.postMessage({
type: 'play',
mod: mod,
sampleRate: audio.sampleRate
});
});
After these changes, when you click the browser window, the first channel of the song starts playing. However, the timing is a bit wrong, and some of the notes are not playing correctly. This is because no effects have been implemented yet. The Elekfunk song uses the currently unimplemented "Set Speed" effect to speed up the playback, and that's why the timing is wrong. Also, to really enjoy the song, all four channels need to be mixed together. The last changes I make in this post are to create a four-channel mixer, and to implement the "Set Speed" effect.
Try it out, so far
You can try this version of the player here
Mixing multiple channels
To mix the four channels of the song together, I need to keep track of the instrument and period for each channel. Each channel needs to have its own sample index, and move forward in the sample independently of the other channels. I create a Channel
class to keep track of this information, and I create four instances of this class in the worklet. The nextOutput
function now loops over all four channels, and adds the output of each channel together. To limit the output to the range of -1.0 to 1.0, I use the Math.tanh
function, which is a hyperbolic tangent function. It has the nice property that the output is always between -1.0 and 1.0, and the output is also scaled to the input.
class Channel {
constructor(worklet) {
this.worklet = worklet;
this.instrument = null;
this.period = 0;
this.sampleSpeed = 0.0;
this.sampleIndex = 0;
}
nextOutput() {
if (!this.instrument || !this.period) return 0.0;
const sample = this.instrument.bytes[this.sampleIndex | 0];
this.sampleIndex += this.sampleSpeed;
if (this.sampleIndex >= this.instrument.length) {
return 0.0;
}
return sample / 256.0;
}
play(note) {
if (note.instrument) {
this.instrument = this.worklet.mod.instruments[note.instrument - 1];
this.sampleIndex = 0;
}
if (note.period) {
this.period = note.period - this.instrument.finetune;
const sampleRate = PAULA_FREQUENCY / this.period;
this.sampleSpeed = sampleRate / this.worklet.sampleRate;
}
}
}
class PlayerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.port.onmessage = this.onmessage.bind(this);
this.mod = null;
this.channels = [ new Channel(this), new Channel(this), new Channel(this), new Channel(this) ];
}
onmessage(e) {
if (e.data.type === 'play') {
this.mod = e.data.mod;
this.sampleRate = e.data.sampleRate;
this.bpm = 125;
this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4;
// Start at the last row of the pattern "before the first pattern"
this.position = -1;
this.rowIndex = 63;
// Immediately move to the first row of the first pattern
this.outputsUntilNextRow = 0;
}
}
nextRow() {
this.outputsUntilNextRow += this.outputsPerRow;
++this.rowIndex;
if (this.rowIndex == 64) {
this.rowIndex = 0;
++this.position;
}
const patternIndex = this.mod.patternTable[this.position];
const pattern = this.mod.patterns[patternIndex];
const row = pattern.rows[this.rowIndex];
for (let i = 0; i < 4; ++i) {
this.channels[i].play(row.notes[i]);
}
}
nextOutput() {
if (!this.mod) return 0.0;
if (this.outputsUntilNextRow <= 0) {
this.nextRow();
}
this.outputsUntilNextRow--;
const rawOutput = this.channels.reduce((acc, channel) => acc + channel.nextOutput(), 0.0);
return Math.tanh(rawOutput);
}
process(inputs, outputs) {
const output = outputs[0];
const channel = output[0];
for (let i = 0; i < channel.length; ++i) {
const value = this.nextOutput();
channel[i] = value;
}
return true;
}
}
Adding the first effect
The first effect I want to implement is the "Set Speed" effect. This effect can be used in two different ways. If the value is less than 32, it sets the number of "ticks" per row. If the value is greater than or equal to 32, it sets the number of beats per minute. A "tick" is a concept of the MOD format, and is normally a 1/6th of a row. Most older MOD files never change the BPM, but instead changes the number of ticks per row to speed up or slow down the song. A lot of effects perform calculations based on the number of ticks per row, so it's important to implement this effect. I'll start by adding an effect
method to the Channel
class, which I can use to implement all the effects. I'll also add a setTicksPerRow
method and a setBpm
method to the PlayerWorklet
class, which I can use to update the number of ticks per row.
play(note) {
if (note.instrument) {
this.instrument = this.worklet.mod.instruments[note.instrument - 1];
this.sampleIndex = 0;
}
if (note.period) {
this.period = note.period - this.instrument.finetune;
const sampleRate = PAULA_FREQUENCY / this.period;
this.sampleSpeed = sampleRate / this.worklet.sampleRate;
}
if (note.effect) {
this.effect(note.effect);
}
}
effect(raw) {
const id = raw >> 8;
const data = raw & 0xff;
if (id == 0x0f) {
if (data >= 1 && data <= 31) {
this.worklet.setTicksPerRow(data)
}
else {
this.worklet.setBpm(data);
}
}
}
onmessage(e) {
if (e.data.type === 'play') {
this.mod = e.data.mod;
this.sampleRate = e.data.sampleRate;
this.setBpm(125);
this.setTicksPerRow(6);
// Start at the last row of the pattern "before the first pattern"
this.position = -1;
this.rowIndex = 63;
// Immediately move to the first row of the first pattern
this.outputsUntilNextRow = 0;
}
}
setTicksPerRow(ticksPerRow) {
this.ticksPerRow = ticksPerRow;
this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4 * this.ticksPerRow / 6;
}
setBpm(bpm) {
this.bpm = bpm;
this.outputsPerRow = this.sampleRate * 60 / this.bpm / 4 * this.ticksPerRow / 6;
}
Try it out
You can try this version of the player here
Conclusion
With these changes, the player can now render a somewhat decently sounding version of Elekfunk by Moby, from the Arte demo. The instruments play correctly and the song is played at the correct speed, but there are still a lot of effects missing. Those will be the subject of the next couple of parts of this series.
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.
Articles in this series: