Playing a full song, almost

#retrocomputing #javascript #demoscene #web-audio

Written by Anders Marzi Tornblad

Published on dev.to

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 0Byte 1Byte 2Byte 3
313029282726252423222120191817161514131211109876543210
I7I6I5I4P11P10P9P8P7P6P5P4P3P2P1P0I3I2I1I0E11E10E9E8E7E6E5E4E3E2E1E0
Format of a single note

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));
        }
    }
}
Added classes to mod.js

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);
        }
    }
}
Updated constructor of class Mod

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);
Updated player-worklet.js that plays one channel

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
    });
});
Updated player.js

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;
        }
    }
}
Adding the Channel class to player-worklet.js
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;
    }
}
Updated the PlayerWorklet with a simple tanh mixer

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);
            }
        }
    }
Updated methods of the Channel class
    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;
    }
Changed and added methods to the PlayerWorklet class

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: