Adding pitch-related effects

#retrocomputing #javascript #demoscene #web-audio

Written by Anders Marzi Tornblad

Published on dev.to

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

The vanilla JavaScript MOD player is progressing nicely. Now it's time for some pitch-related effects – vibrato, portamento and arpeggio. I'll also add variants of these effects that let you slide the volume up and down, at the same time as the pitch is changing. These are the effects that I want to implement:

Effect 1 – Slide up

This effect slides the pitch up by decreasing the period of the note. Each tick, the period is reduced by the value specified in the effect parameter. To manage this, I need to add a currentPeriod variable, and a periodDelta variable to the Channel class, and update the period in the performTick method. The code for calculating the sample speed is moved from the play method to the performTick method, so that the speed is updated every tick. The period can never be less than 113 (B-3, the highest note in the MOD format)or more than 856 (C-1, the lowest note), so I clamp the period to that range:

// Showing only the changed methods of the Channel class
    play(note) {
        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.sampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.currentPeriod = this.period;
        }

        this.effect(note.effect);
    }

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

        if (this.periodDelta) {
            this.currentPeriod += this.periodDelta;
            if (this.currentPeriod < 113) this.currentPeriod = 113;
            if (this.currentPeriod > 856) this.currentPeriod = 856;
        }

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 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 SLIDE_UP:
                this.periodDelta = -data;
                break;

        // The rest of the effect method is unchanged
Adding the first pitch sliding effect to the Channel class

Effect 2 – Slide down

Now that the slide up effect is working, the slide down effect is easy to implement. It comes down to just changing the sign of the periodDelta variable, and the rest is already done:

            case SLIDE_DOWN:
                this.periodDelta = data;
                break;
Adding a single case to the effect method of the Channel class

Effect 3 – Tone portamento

The Tone portamento effect is a combination of the slide up and slide down effects, and it uses the period of the note that is played to determine the direction of the slide. When the pitch has reached the target pitch, the effect is stopped. Tone portamento is also the first effect to implement that "remembers" its parameter between lines. This means that you can play a note and set the a slow portamento effect, using for example 301, and then any consecutive portamento effects with a value of zero will use the same speed. To implement this, I need to make sure that a note playing with the portamento effect will not set the currentPeriod immediately. Instead, the period is only set by the performTick method. One thing to note is that a note playing with this command will not start the sample from the beginning, but instead continue from where it was when the portamento effect was started. To implement this, I need to delay setting the sampleIndex variable until the effect method is called.

    constructor(worklet) {
        // Skipping unchanged lines
        this.portamentoSpeed = 0;
    }

    performTick() {
        // Skipping unchanged volume handling

        if (this.periodDelta) {
            if (this.portamento) {
                if (this.currentPeriod != this.period) {
                    // Which direction to slide?
                    const sign = Math.sign(this.period - this.currentPeriod);
                    // Don't slide past the target period
                    const distance = Math.abs(this.currentPeriod - this.period);
                    const diff = Math.min(distance, this.periodDelta);
                    this.currentPeriod += sign * diff;
                }
            }
            else {
                this.currentPeriod += this.periodDelta;
                if (this.currentPeriod < 113) this.currentPeriod = 113;
                if (this.currentPeriod > 856) this.currentPeriod = 856;
            }
        }
        
        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    play(note) {
        // Keep track of whether the sample should start from the beginning
        this.setSampleIndex = false;

        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.setSampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        // Keep track of whether period should change immediately
        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
        }

        // setSampleIndex and setCurrentPeriod could change back to false in the effect method
        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 0;
        this.portamento = false;

        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 SLIDE_UP:
                this.periodDelta = -data;
                break;
            case SLIDE_DOWN:
                this.periodDelta = data;
                break;
            case TONE_PORTAMENTO:
                this.portamento = true;
                if (data) this.portamentoSpeed = data;
                this.periodDelta = this.portamentoSpeed;
                this.setCurrentPeriod = false;
                this.setSampleIndex = false;
                break;
Changes to constructor, performTick, play and effect methods of the Channel class

Effect 4 – Vibrato

Vibrato affects the period of the note at each tick. The effective period is calculated by adding a vibrato value to the current period. The upper 4 bits of the parameter is the vibrato speed, and the lower 4 bits is the vibrato depth. It is actually quite tricky to find any good documentation on the correct implementation of this effect, but I found an explanation to get me started in a very old Multimedia Wiki. It appears to use a 64-step sine wave to change the period, and the speed is the number of steps forward in the sine wave per tick. Depth and speed of the vibrato effect are remembered individually between notes for each channel.

    constructor(worklet) {
        // Added three variables
        this.vibratoDepth = 0;
        this.vibratoSpeed = 0;
        this.vibratoIndex = 0;
    }

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

        if (this.vibrato) {
            this.vibratoIndex = (this.vibratoIndex + this.vibratoSpeed) % 64;
            this.currentPeriod = this.period + Math.sin(this.vibratoIndex / 64 * Math.PI * 2) * this.vibratoDepth;
        }
        else if (this.periodDelta) {
            if (this.portamento) {
                if (this.currentPeriod != this.period) {
                    const sign = Math.sign(this.period - this.currentPeriod);
                    const distance = Math.abs(this.currentPeriod - this.period);
                    const diff = Math.min(distance, this.periodDelta);
                    this.currentPeriod += sign * diff;
                }
            }
            else {
                this.currentPeriod += this.periodDelta;
            }
        }
        
        if (this.currentPeriod < 113) this.currentPeriod = 113;
        if (this.currentPeriod > 856) this.currentPeriod = 856;
        
        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    play(note) {
        this.setSampleIndex = false;

        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.setSampleIndex = 0;
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
        }

        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }

    effect(raw) {
        this.volumeSlide = 0;
        this.periodDelta = 0;
        this.portamento = false;
        this.vibrato = false;

        if (!raw) return;

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

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

        switch (id) {
            // Added one case
            case VIBRATO:
                const speed = data >> 4;
                const depth = data & 0x0f;
                if (speed) this.vibratoSpeed = speed;
                if (depth) this.vibratoDepth = depth;
                this.vibrato = true;
                break;
Changes to the constructor, performTick, play, and effect methods of the Channel class

Effect 5 – Tone portamento + volume slide

This effect is a combination of effects 3 and A, which are already implemented. The only changes needed to the effect method is to add a new case to the switch statement, and combine the two existing effects:

            case TONE_PORTAMENTO_WITH_VOLUME_SLIDE:
                this.portamento = true;
                this.setCurrentPeriod = false;
                this.setSampleIndex = false;
                this.periodDelta = this.portamentoSpeed;
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;
Adding a new switch case to the effect method of the Channel class

Effect 6 – Vibrato + volume slide

Just like effect 5 is a combination of effects 3 and A, effect 6 is a combination of effects 4 and A.This means that everything is already in place to implement this effect. The only thing that needs to be done is to add a case for it in the effect method of the Channel class.

            case VIBRATO_WITH_VOLUME_SLIDE:
                this.vibrato = true;
                if (data & 0xf0) {
                    this.volumeSlide = data >> 4;
                }
                else if (data & 0x0f) {
                    this.volumeSlide = -(data & 0x0f);
                }
                break;
Adding a new switch to the effect method

Effect 0 – Arpeggio

This effect is used to play a chord by playing three notes in quick succession. The first note is the note that is played, and the second and third notes are played by adding the arpeggio values to the note. The arpeggio values are set by the upper 4 bits and the lower 4 bits of the parameter. Every tick, the period is changed to the next note in the chord.

    constructor(worklet) {
        // Adding one line:
        this.arpeggio = false;
    }

    performTick() {
        if (this.volumeSlide && this.worklet.tick > 0) {
            // Unchanged
        }

        if (this.vibrato) {
            // Unchanged
        }
        else if (this.periodDelta) {
            // Unchanged
        }
        else if (this.arpeggio) {
            const index = this.worklet.tick % this.arpeggio.length;
            const halfNotes = this.arpeggio[index];
            this.currentPeriod = this.period / Math.pow(2, halfNotes / 12);
        }

        if (this.currentPeriod < 113) this.currentPeriod = 113;
        if (this.currentPeriod > 856) this.currentPeriod = 856;

        const sampleRate = PAULA_FREQUENCY / this.currentPeriod;
        this.sampleSpeed = sampleRate / this.worklet.sampleRate;
    }

    effect(raw) {
        // Add one line at the beginning
        this.arpeggio = false;

        // In the switch statement:
        switch (id) {
            case ARPEGGIO:
                this.arpeggio = [0, data >> 4, data & 0x0f];
                break;
Changes to the Channel class

Some bug fixes

Now, the three songs I have chosen should play almost perfectly. However, when listening to them, I realize that there are some bugs that need to be fixed. The first one is that notes should restart from the beginning of the sample when there is a new note, even if the instrument is set to zero. I discovered this by listening to the song from Phenomena Enigma. In pattern 30 (song position 45) channel 3 sounds almost silent. The fix is to move the code that sets the sample index to another position:

    play(note) {
        if (note.instrument) {
            this.instrument = this.worklet.mod.instruments[note.instrument - 1];
            this.volume = this.instrument.volume;
            this.currentVolume = this.volume;
        }

        this.setSampleIndex = false;
        this.setCurrentPeriod = false;

        if (note.period) {
            this.period = note.period - this.instrument.finetune;
            this.setCurrentPeriod = true;
            this.setSampleIndex = 0;
        }

        this.effect(note.effect);

        if (this.setCurrentPeriod) {
            this.currentPeriod = this.period;
        }

        if (this.setSampleIndex !== false) {
            this.sampleIndex = this.setSampleIndex;
        }
    }
Fixing sample retriggers in the play method

The other bug is that the volume slide effect runs a bit too fast. When listening to the opening of Livin' Insanity , in patterns 2 and 4 (song positions 0 and 1) the volume goes down to zero too quickly. After some googling, I found that the volume slide effect should not be run on the first tick of each row. So for a normal speed setting of 6 ticks per row, the volume slide effect should not run on tick 0, but should run on ticks 1, 2, 3, 4 and 5.

    performTick() {
        if (this.volumeSlide && this.worklet.tick > 0) {
            this.currentVolume += this.volumeSlide;
            if (this.currentVolume < 0) this.currentVolume = 0;
            if (this.currentVolume > 64) this.currentVolume = 64;
        }
A small change to the performTick method

Try it out

You can try this version of the player here

Conclusion

The only two effects that are missing right now for reaching my goal are Retrigger note and Delay note, and I'll implement those in the next article. Those effects are not used in the song from Enigma, so that one actually plays correctly right now. So far, this has been a fun challenge, and I have learned a lot about the inner workings of the MOD format.

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: