Implementing looping and the first effects

#retrocomputing #javascript #demoscene #web-audio

Written by Anders Marzi Tornblad

Published on dev.to

This is part 4 of the JavaScript MOD Player series. If you haven't read the first part, here it is: Generating sound in modern Web Audio API

Looping samples

Currently, the player can only play "one-off" samples, which means that they are played once and then stopped. This means that looping samples don't work properly. In the instrument data, there are two fields called repeatOffset and repeatLength. These are used to define the loop points of the sample. For samples without looping, repeatOffset is set to 0 and repeatLength is set to 0 or 2, depending on which version of the MOD format is used. I add a new member variable to the Instrument class to check if the sample is looped: isLooped, and use that in the Channel class to determine if the sample should be looped or not.

class Instrument {
    constructor(modfile, index, sampleStart) {
        const data = new Uint8Array(modfile, 20 + index * 30, 30);
        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]; // 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);
        this.isLooped = this.repeatOffset != 0 || this.repeatLength > 2;
    }
}
Updated Instrument constructor
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0;
    }
Updated nextOutput method of the Channel class

Finding the most important effects

The MOD format defines a lot of effects, but some of them are not used very often in demo songs. My goal for this article series is to be able to play three songs:

To find out which effects are used by those three songs, I refactor the effect method of the Channel class to log the effect id of each unimplemented effect. After reading some more about the different effect IDs, I also find out that the 0x0E effect is used as a prefix for extended effects, so that is also added.

// Declared at the top of the file, outside of the class definition
const unimplementedEffects = new Set();
const EXTENDED = 0x0e;
const SET_SPEED = 0x0f;

// Updated effect method of the Channel class
    effect(raw) {
        let id = raw >> 8;
        let data = raw & 0xff;

        if (id == EXTENDED) {
            // Effect ID can be E0..EF
            id = (id << 4) | (data >> 4);
            data = data & 0x0f;
        }

        if (id == SET_SPEED) {
            if (data >= 1 && data <= 31) {
                this.worklet.setTicksPerRow(data);
            }
            else {
                this.worklet.setBpm(data);
            }
        }
        else {
            if (!unimplementedEffects.has(id)) {
                unimplementedEffects.add(id);
                console.log('Unimplemented effect ' + id.toString(16));
            }
        }
    }
Added effect logging to the Channel class

After running the player with the three songs, I get the following list of effects to implement, and I can add definitions for them to the player-worklet.js file:

const ARPEGGIO = 0x00;
const SLIDE_UP = 0x01;
const SLIDE_DOWN = 0x02;
const TONE_PORTAMENTO = 0x03;
const VIBRATO = 0x04;
const TONE_PORTAMENTO_WITH_VOLUME_SLIDE = 0x05;
const VIBRATO_WITH_VOLUME_SLIDE = 0x06;
const SAMPLE_OFFSET = 0x09;
const VOLUME_SLIDE = 0x0A;
const SET_VOLUME = 0x0C;
const PATTERN_BREAK = 0x0D;
const EXTENDED = 0x0e;
const SET_SPEED = 0x0f;
const RETRIGGER_NOTE = 0xe9;
const DELAY_NOTE = 0xed;
Added effect ids at the top of player-worklet.js

In the documentation I have found, the effects are described in a very technical way, but with a lot of details missing. Also, it is impossible to know exactly what the effects are supposed to sound like without hearing them in action. Fortunately, I have found a great source of information in the form of a YouTube playlist by demoscene musician Alex Löfgren (Wasp) where a lot of the effects are explained in detail, and shown in action. Throughout the implementation of the effects, I'm' listening to the examples in the playlist to make sure that I get them right, and also making minimal test songs in ProTracker to try out the effects.

In this article, I implement four of the effects, and I leave the rest for future articles:

Effect C – Set volume

So far, all samples are playing at full volume, which is not correct. First of all, each instrument has a volume property, which is a value between 0 and 64 (the maximum volume for audio in the Amiga Paula chip). Second, the Set volume effect can be used to override the volume of the instrument. It can be set when a note is triggered, or changed at any time during the note. To implement this, I add a volume property to the Channel class, update the nextOutput method to use the channel volume instead of the instrument volume, and update the effect method to set the volume when the effect is encountered.

    constructor(worklet) {
        this.worklet = worklet;
        this.instrument = null;
        this.playing = false;
        this.period = 0;
        this.sampleSpeed = 0.0;
        this.sampleIndex = 0;
        this.volume = 64;
    }
Added a volume variable in the Channel constructor
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0 * this.volume / 64;
    }
Implemented volume in the nextOutput method
        switch (id) {
            case SET_VOLUME:
                this.volume = data;
                break;
            case SET_SPEED:
                if (data >= 1 && data <= 31) {
                    this.worklet.setTicksPerRow(data);
                }
                else {
                    this.worklet.setBpm(data);
                }
                break;
            default:
                if (!unimplementedEffects.has(id)) {
                    unimplementedEffects.add(id);
                    console.log('Unimplemented effect ' + id.toString(16));
                }
                break;
        }
Added the Set Volume command to the effect method

Effect 9 – Sample offset

This effect makes the player set the position in the sample to a specific value. The value is the upper eight bits of the sample offset, so it must be multiplied by 256 to get the actual offset. The effect can be used to jump forward and backward in a sample while it is playing, or to start from a selected position when triggering a note. Implementing this is very simple, I just need to update the effect method to set the sample index when the effect is encountered.

            case SAMPLE_OFFSET:
                this.sampleIndex = data * 256;
                break;
A new case in the effect method switch statement

Effect D – Pattern break

This effect makes the player jump to a specific row in the next pattern. The jump takes place after the current row has finished playing, so the effect is not triggered immediately. The value of the effect is the row number to start playing in the next pattern, in binary coded decimal notation. The first hexadecimal digit is the "tens" of the row number, and the second hexadecimal digit is the "ones". To make this work, I add a patternBreak member variable of the PlayerWorklet class that I initialize to false in the constructor, and I add a setPatternBreak method , that I call from the effect method when the effect is encountered.

    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) ];
        this.patternBreak = false;
    }
Setting a patternBreak variable to the PlayerWorklet constructor
    setPatternBreak(row) {
        this.patternBreak = row;
    }
The setPatternBreak method in the PlayerWorklet class
    nextRow() {
        ++this.rowIndex;
        if (this.patternBreak !== false) {
            this.rowIndex = this.patternBreak;
            ++this.position;
            this.patternBreak = false;
        }
        else if (this.rowIndex == 64) {
            this.rowIndex = 0;
            ++this.position;
        }

        // The rest of the nextRow method is unchanged
Updated nextRow method in the PlayerWorklet class
            case PATTERN_BREAK:
                const row = (data >> 4) * 10 + (data & 0x0f);
                this.worklet.setPatternBreak(row);
                break;
Adding a new case to the effect method in the Channel class

Effect A – Volume slide

The Volume slide effect works by repeatedly changing the volume of the channel, a number of times per row. Since the number of times is the same as the ticksPerRow value set by the Set speed effect, there is already an idea of "ticks" in the playback code. Unfortunately, the player doesn't keep track of the current tick, so I need to add a tick member variable and a nextTick method to the PlayerWorklet class. Instead of keeping track of when to move to the next row, I will keep track of when to move to the next tick, and call nextRow when the tick reaches the ticksPerRow value. When all this is done, I can implement the Volume slide effect, and other effects that use the tick count.

// Only the changed methods are shown here
    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 tick of the pattern "before the first pattern"
            this.position = -1;
            this.rowIndex = 63;
            this.tick = 5;
            this.ticksPerRow = 6;

            // Immediately move to the first row of the first pattern
            this.outputsUntilNextTick = 0;
        }
    }

    setTicksPerRow(ticksPerRow) {
        this.ticksPerRow = ticksPerRow;
    }

    setBpm(bpm) {
        this.bpm = bpm;
        this.outputsPerTick = this.sampleRate * 60 / this.bpm / 4 / 6;
    }

    nextTick() {
        ++this.tick;
        if (this.tick == this.ticksPerRow) {
            this.tick = 0;
            this.nextRow();
        }

        for (let i = 0; i < 4; ++i) {
            this.channels[i].performTick();
        }
    }

    nextOutput() {
        if (!this.mod) return 0.0;

        if (this.outputsUntilNextTick <= 0) {
            this.nextTick();
            this.outputsUntilNextTick += this.outputsPerTick;
        }

        this.outputsUntilNextTick--;

        const rawOutput = this.channels.reduce((acc, channel) => acc + channel.nextOutput(), 0.0);
        return Math.tanh(rawOutput);
    }
Changes to the PlayerWorklet class
// Only the changed methods are shown here
    nextOutput() {
        if (!this.instrument || !this.period) return 0.0;
        const sample = this.instrument.bytes[this.sampleIndex | 0];

        this.sampleIndex += this.sampleSpeed;

        if (this.instrument.isLooped) {
            if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) {
                this.sampleIndex = this.instrument.repeatOffset;
            }
        }
        else if (this.sampleIndex >= this.instrument.length) {
            return 0.0;
        }

        return sample / 256.0 * this.currentVolume / 64;
    }

    performTick() {
        if (this.volumeSlide) {
            this.currentVolume += this.volumeSlide;
            if (this.currentVolume < 0) this.currentVolume = 0;
            if (this.currentVolume > 64) this.currentVolume = 64;
        }
    }

    play(note) {
        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.sampleIndex = 0;
            this.currentVolume = this.instrument.volume;
        }
        // The rest of the play method is unchanged...
    }

    effect(raw) {
        this.volumeSlide = 0;

        if (!raw) return;

        let id = raw >> 8;
        let data = raw & 0xff;

        if (id == EXTENDED) {
            id = (id << 4) | (data >> 4);
            data = data & 0x0f;
        }

        switch (id) {
            case SET_VOLUME:
                this.volume = data;
                this.currentVolume = this.volume;
                break;
            case VOLUME_SLIDE:
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;
        // The rest of the effect method is unchanged
Changes to the Channel class

Try it out

You can try this version of the player here

Conclusion

Effects are a very important part of the musical experience of MOD files, and after just these four effects, the reference MODs are starting to sound much better. In the next couple of article, I will implement the remaining effects, and then I will move on to implementing more things that will help me use MOD files in a demo.

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: