DEV Community

Cover image for Implementing looping and the first effects
Anders Marzi Tornblad
Anders Marzi Tornblad

Posted on • Originally published at atornblad.se

Implementing looping and the first effects

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
    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;
    }
Enter fullscreen mode Exit fullscreen mode

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));
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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:

  • Effect 0 – Arpeggio
  • Effect 1 – Slide up
  • Effect 2 – Slide down
  • Effect 3 – Tone portamento
  • Effect 4 – Vibrato
  • Effect 5 – Tone portamento + volume slide
  • Effect 6 – Vibrato + volume slide
  • Effect 9 – Sample offset
  • Effect A – Volume slide
  • Effect C – Set volume
  • Effect D – Pattern break
  • Effect E9 – Retrigger note
  • Effect ED – Delay note
  • Effect F – Set speed (done)
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;
Enter fullscreen mode Exit fullscreen mode

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
  • Effect 9 – Sample offset
  • Effect D – Pattern break
  • Effect A – Volume slide

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;
    }
Enter fullscreen mode Exit fullscreen mode
    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;
    }
Enter fullscreen mode Exit fullscreen mode
        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;
        }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode
    setPatternBreak(row) {
        this.patternBreak = row;
    }
Enter fullscreen mode Exit fullscreen mode
    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
Enter fullscreen mode Exit fullscreen mode
            case PATTERN_BREAK:
                const row = (data >> 4) * 10 + (data & 0x0f);
                this.worklet.setPatternBreak(row);
                break;
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode
// 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;
        }
    }

    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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)