Adding pitch-related effects
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 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(done)Effect A – Volume slide(done)Effect C – Set volume(done)Effect D – Pattern break(done)- Effect E9 – Retrigger note (in next article)
- Effect ED – Delay note (in next article)
Effect F – Set speed(done)
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
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;
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;
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;
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;
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;
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;
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;
}
}
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;
}
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: