Implementing looping and the first effects
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;
}
}
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;
}
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:
- Musiklinjen: The song from Enigma by Phenomena
- Livin' Instanity: The first song from Arte by Sanity
- Elekfunk: The second song from Arte by Sanity
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));
}
}
}
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;
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;
}
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;
}
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;
}
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;
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;
}
setPatternBreak(row) {
this.patternBreak = row;
}
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
case PATTERN_BREAK:
const row = (data >> 4) * 10 + (data & 0x0f);
this.worklet.setPatternBreak(row);
break;
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);
}
// 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
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: