├── LICENSE.md ├── README.md ├── index.html ├── loader.js ├── mod-player-worklet.js ├── mod.js ├── player.js └── style.css /LICENSE.md: -------------------------------------------------------------------------------- 1 | # LICENSE 2 | 3 | This work is licensed under a [Creative Commons Attribution-NonCommercial 4.0 International License](https://creativecommons.org/licenses/by-nc/4.0/). 4 | 5 | This is a human-readable summary of *(and not a substitute for)* the [license](https://creativecommons.org/licenses/by-nc/4.0/). 6 | 7 | ## You are free to: 8 | 9 | - **Share** – copy and redistribute the material in any medium or format 10 | - **Adapt** – remix, transform, and build upon the material 11 | 12 | The licensor cannot revoke these freedoms as long as you follow the license terms. 13 | 14 | ## Under the following terms: 15 | 16 | - **Attribution** – You must give *appropriate credit*, provide a link to the license, and *indicate if changes were made*. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 17 | - **NonCommercial** – You may not use the material for *commercial purposes*. 18 | - **No additional restrictions** – You may not apply legal terms or *technological measures* that legally restrict others from doing anything the license permits. 19 | 20 | ## Notices: 21 | 22 | You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable *exception or limitation*. 23 | 24 | No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as *publicity, privacy, or moral rights* may limit how you use the material. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JS-MOD-PLAYER 2 | 3 | This is a MOD player, developed in vanilla JavaScript using modern (2022) Web Audio and Audio Worklet APIs. 4 | 5 | Try it out live on my [GitHub Page](https://atornblad.github.io/js-mod-player). 6 | 7 | Read about how it was made on [my Blog](https://atornblad.se/javascript-mod-player). 8 | 9 | License: [Creative Commons Attribution-NonCommercial 4.0 International License](http://creativecommons.org/licenses/by-nc/4.0/) 10 | 11 | --- 12 | ## Getting started 13 | Add a Protracker 2.3 compatible MOD file to your project. 14 | 15 | Add the following `script` element to your html file: 16 | 17 | ```html 18 | 26 | ``` 27 | 28 | This will create an instance of the `ModPlayer` class and load the MOD file. When the user clicks the window, the music will start to play. Web Audio API does not allow audio to play until the user has interacted with the page, so you have to wait for some type of user input, like a click or a tap. 29 | 30 | ## Event system 31 | 32 | To use music as a time source, or for synchronizing effects and events in a demo, you can use the `watch` 33 | and `watchRows` methods to get notified at the exact moment different rows are played. 34 | 35 | ### Examples 36 | 37 | ```javascript 38 | // Calls the switchToNextScene method when the music 39 | // reaches row 16 in position 4. 40 | player.watch(4, 16, switchToNextScene); 41 | 42 | // Calls the flashTheScreen method every 4 rows 43 | player.watchRows((pos, row) => { 44 | if ((row % 4) === 0) { 45 | flashTheScreen(); 46 | } 47 | }); 48 | 49 | // Logs to the console when the music stops due to 50 | // the Set Speed Zero (F00) effect 51 | player.watchStop(() => console.log('Stopped!')); 52 | ``` 53 | 54 | # Specifications 55 | 56 | ## ModPlayer class 57 | 58 | Public methods of the `ModPlayer` class: 59 | 60 | - `async load(url)` Loads a MOD file from a url. 61 | - `unload()` Unloads a loaded MOD file and frees resources. 62 | - `play()` Starts playing music. 63 | - `stop()` Stops playing. 64 | - `resume()` Resumes playing after a call to `stop()`. 65 | - `setVolume(volume)` Sets the volume of an internal gain node. Default value: 0.3 66 | - `setRow(position, row)` Jumps to a specific time in the music. 67 | - `watch(position, row, callback)` Registers a callback for a position and row. 68 | - `watchRows(callback)` Registers a callback for all rows. 69 | - `watchStop(callback)` Registers a callback for the "Set Speed Zero `F00` effect" 70 | 71 | Public member variables of the `ModPlayer` class: 72 | 73 | - `mod : Mod` The loaded MOD file. 74 | - `playing : Boolean` Returns true if music is currently playing. 75 | 76 | ## Mod class 77 | 78 | Public member variables of the `ModPlayer` class: 79 | 80 | - `name : String` The name of the MOD file. 81 | - `length : Number` The number of positions. 82 | - `patternTable : Array of Number` The pattern index for each position. 83 | - `instruments : Array of Instrument` The instruments contained in the MOD file. 84 | - `patterns : Array of Pattern` The patterns contained in the MOD file. 85 | 86 | 87 | ## Pattern class 88 | 89 | Public member variables of the `Pattern` class: 90 | 91 | - `rows : Array of Row` The 64 rows of notes contained in the pattern. 92 | 93 | ## Row class 94 | 95 | Public member variables of the `Row` class: 96 | 97 | - `notes : Array of Note` The 4 notes containt in the row. 98 | 99 | ## Note class 100 | 101 | Public member variables of the `Note` class: 102 | 103 | - `instrument : Number` The instrument number, between 1 and 31, or 0 for not changing. 104 | - `period : Number` The 12-bit period of the note, or 0 for not changing. 105 | - `effect : Number` The 12-bit effect data for the note. 106 | 107 | ## Instrument class 108 | 109 | Public member variables of the `Instrument` class: 110 | 111 | - `name : String` Name of the instrument, from the MOD file. 112 | - `length : Number` The length of the sample, measured in bytes. 113 | - `finetune : Number` A signed 4 bit integer for finetuning the instrument. 114 | - `volume : Number` The default volume, between 0 and 64. 115 | - `repeatOffset : Number` For looping samples, the start of the loop. 116 | - `repeatLength : Number` For looping samples, the length of the loop. 117 | - `bytes : Int8Array` The raw sample data in signed bytes. 118 | - `isLooped : Boolean` True if the sample is looped. 119 | 120 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | JS-MOD-player by Anders Marzi Tornblad 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |

JS-MOD-player

25 |

Vanilla JavaScript MOD player, made by Anders Marzi Tornblad

26 |

GitHub repository: atornblad/js-mod-player

27 |

License: Creative Commons Attribution-NonCommercial 4.0 International License

28 |
29 |
30 |

Modules

31 |

These modules are loaded from The Mod Archive.

32 |

33 | 34 | 35 | 36 |

37 |

38 | 39 | 40 | 41 |

42 |

43 | 44 | 45 |

46 |

47 | 48 | 49 |

50 |

51 |

Playing song --- (from The Mod Archive)
52 | Mod file source:
----

53 |
54 | 67 | 68 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /loader.js: -------------------------------------------------------------------------------- 1 | // Import the Mod class 2 | import { Mod } from './mod.js'; 3 | 4 | // Load MOD file from a url 5 | export const loadMod = async (url) => { 6 | const response = await fetch(url); 7 | const arrayBuffer = await response.arrayBuffer(); 8 | const mod = new Mod(arrayBuffer); 9 | return mod; 10 | }; 11 | -------------------------------------------------------------------------------- /mod-player-worklet.js: -------------------------------------------------------------------------------- 1 | const PAULA_FREQUENCY = 3546894.6; 2 | 3 | const ARPEGGIO = 0x00; 4 | const SLIDE_UP = 0x01; 5 | const SLIDE_DOWN = 0x02; 6 | const TONE_PORTAMENTO = 0x03; 7 | const VIBRATO = 0x04; 8 | const TONE_PORTAMENTO_WITH_VOLUME_SLIDE = 0x05; 9 | const VIBRATO_WITH_VOLUME_SLIDE = 0x06; 10 | const SAMPLE_OFFSET = 0x09; 11 | const VOLUME_SLIDE = 0x0A; 12 | const SET_VOLUME = 0x0C; 13 | const PATTERN_BREAK = 0x0D; 14 | const EXTENDED = 0x0e; 15 | const SET_SPEED = 0x0f; 16 | const RETRIGGER_NOTE = 0xe9; 17 | const DELAY_NOTE = 0xed; 18 | 19 | const unimplementedEffects = new Set(); 20 | 21 | class Channel { 22 | constructor(worklet) { 23 | this.worklet = worklet; 24 | this.instrument = null; 25 | this.playing = false; 26 | this.period = 0; 27 | this.currentPeriod = 0; 28 | this.portamentoSpeed = 0; 29 | this.periodDelta = 0; 30 | this.vibratoDepth = 0; 31 | this.vibratoSpeed = 0; 32 | this.vibratoIndex = 0; 33 | this.arpeggio = false; 34 | this.sampleSpeed = 0.0; 35 | this.sampleIndex = 0; 36 | this.volume = 64; 37 | this.currentVolume = 64; 38 | } 39 | 40 | nextOutput() { 41 | if (!this.instrument || !this.period) return 0.0; 42 | const sample = this.instrument.bytes[this.sampleIndex | 0]; 43 | 44 | this.sampleIndex += this.sampleSpeed; 45 | 46 | if (this.instrument.isLooped) { 47 | if (this.sampleIndex >= this.instrument.repeatOffset + this.instrument.repeatLength) { 48 | this.sampleIndex = this.instrument.repeatOffset; 49 | } 50 | } 51 | else if (this.sampleIndex >= this.instrument.length) { 52 | return 0.0; 53 | } 54 | 55 | return sample / 256.0 * this.currentVolume / 64; 56 | } 57 | 58 | performTick() { 59 | if (this.volumeSlide && this.worklet.tick > 0) { 60 | this.currentVolume += this.volumeSlide; 61 | if (this.currentVolume < 0) this.currentVolume = 0; 62 | if (this.currentVolume > 64) this.currentVolume = 64; 63 | } 64 | 65 | if (this.vibrato) { 66 | this.vibratoIndex = (this.vibratoIndex + this.vibratoSpeed) % 64; 67 | this.currentPeriod = this.period + Math.sin(this.vibratoIndex / 64 * Math.PI * 2) * this.vibratoDepth; 68 | } 69 | else if (this.periodDelta) { 70 | if (this.portamento) { 71 | if (this.currentPeriod != this.period) { 72 | const sign = Math.sign(this.period - this.currentPeriod); 73 | const distance = Math.abs(this.currentPeriod - this.period); 74 | const diff = Math.min(distance, this.periodDelta); 75 | this.currentPeriod += sign * diff; 76 | } 77 | } 78 | else { 79 | this.currentPeriod += this.periodDelta; 80 | } 81 | } 82 | else if (this.arpeggio) { 83 | const index = this.worklet.tick % this.arpeggio.length; 84 | const halfNotes = this.arpeggio[index]; 85 | this.currentPeriod = this.period / Math.pow(2, halfNotes / 12); 86 | } 87 | else if (this.retrigger && (this.worklet.tick % this.retrigger) == 0) { 88 | this.sampleIndex = 0; 89 | } 90 | else if (this.delayNote === this.worklet.tick) { 91 | this.instrument = this.setInstrument; 92 | this.volume = this.setVolume; 93 | this.currentVolume = this.volume; 94 | this.period = this.setPeriod; 95 | this.currentPeriod = this.period; 96 | this.sampleIndex = 0; 97 | } 98 | 99 | if (this.currentPeriod < 113) this.currentPeriod = 113; 100 | if (this.currentPeriod > 856) this.currentPeriod = 856; 101 | 102 | const sampleRate = PAULA_FREQUENCY / this.currentPeriod; 103 | this.sampleSpeed = sampleRate / this.worklet.sampleRate; 104 | } 105 | 106 | play(note) { 107 | this.setInstrument = false; 108 | this.setVolume = false; 109 | this.setPeriod = false; 110 | this.delayNote = false; 111 | 112 | if (note.instrument) { 113 | this.setInstrument = this.worklet.mod.instruments[note.instrument]; 114 | this.setVolume = this.setInstrument.volume; 115 | } 116 | 117 | this.setSampleIndex = false; 118 | this.setCurrentPeriod = false; 119 | 120 | if (note.period) { 121 | const instrument = this.setInstrument || this.instrument; 122 | const finetune = instrument && instrument.finetune || 0; 123 | this.setPeriod = note.period - finetune; 124 | this.setCurrentPeriod = true; 125 | this.setSampleIndex = 0; 126 | } 127 | 128 | this.effect(note); 129 | 130 | if (this.delayNote) return; 131 | 132 | if (this.setInstrument) { 133 | this.instrument = this.setInstrument; 134 | } 135 | 136 | if (this.setVolume !== false) { 137 | this.volume = this.setVolume; 138 | this.currentVolume = this.volume; 139 | } 140 | 141 | if (this.setPeriod) { 142 | this.period = this.setPeriod; 143 | } 144 | 145 | if (this.setCurrentPeriod) { 146 | this.currentPeriod = this.period; 147 | } 148 | 149 | if (this.setSampleIndex !== false) { 150 | this.sampleIndex = this.setSampleIndex; 151 | } 152 | } 153 | 154 | effect({hasEffect, effectId, effectData, effectHigh, effectLow}) { 155 | this.volumeSlide = 0; 156 | this.periodDelta = 0; 157 | this.portamento = false; 158 | this.vibrato = false; 159 | this.arpeggio = false; 160 | this.retrigger = false; 161 | this.delayNote = false; 162 | 163 | if (!hasEffect) return; 164 | 165 | switch (effectId) { 166 | case ARPEGGIO: 167 | this.arpeggio = [0, effectHigh, effectLow]; 168 | break; 169 | case SLIDE_UP: 170 | this.periodDelta = -effectData; 171 | break; 172 | case SLIDE_DOWN: 173 | this.periodDelta = effectData; 174 | break; 175 | case TONE_PORTAMENTO: 176 | this.portamento = true; 177 | if (effectData) this.portamentoSpeed = effectData; 178 | this.periodDelta = this.portamentoSpeed; 179 | this.setCurrentPeriod = false; 180 | this.setSampleIndex = false; 181 | break; 182 | case VIBRATO: 183 | if (effectHigh) this.vibratoSpeed = effectHigh; 184 | if (effectLow) this.vibratoDepth = effectLow; 185 | this.vibrato = true; 186 | break; 187 | case TONE_PORTAMENTO_WITH_VOLUME_SLIDE: 188 | this.portamento = true; 189 | this.setCurrentPeriod = false; 190 | this.setSampleIndex = false; 191 | this.periodDelta = this.portamentoSpeed; 192 | if (effectHigh) this.volumeSlide = effectHigh; 193 | else if (effectLow) this.volumeSlide = -effectLow; 194 | break; 195 | case VIBRATO_WITH_VOLUME_SLIDE: 196 | this.vibrato = true; 197 | if (effectHigh) this.volumeSlide = effectHigh; 198 | else if (effectLow) this.volumeSlide = -effectLow; 199 | break; 200 | case VOLUME_SLIDE: 201 | if (effectHigh) this.volumeSlide = effectHigh; 202 | else if (effectLow) this.volumeSlide = -effectLow; 203 | break; 204 | case SAMPLE_OFFSET: 205 | this.setSampleIndex = effectData * 256; 206 | break; 207 | case SET_VOLUME: 208 | this.setVolume = effectData; 209 | break; 210 | case PATTERN_BREAK: 211 | const row = effectHigh * 10 + effectLow; 212 | this.worklet.setPatternBreak(row); 213 | break; 214 | case SET_SPEED: 215 | if (effectData >= 1 && effectData <= 31) { 216 | this.worklet.setTicksPerRow(effectData); 217 | } 218 | else { 219 | this.worklet.setBpm(effectData); 220 | } 221 | break; 222 | case RETRIGGER_NOTE: 223 | this.retrigger = effectData; 224 | break; 225 | case DELAY_NOTE: 226 | this.delayNote = effectData; 227 | break; 228 | default: 229 | if (!unimplementedEffects.has(effectId)) { 230 | unimplementedEffects.add(effectId); 231 | console.log(`Unimplemented effect ${effectId.toString(16)}`); 232 | } 233 | break; 234 | } 235 | } 236 | } 237 | 238 | class ModPlayerWorklet extends AudioWorkletProcessor { 239 | constructor() { 240 | super(); 241 | this.port.onmessage = this.onmessage.bind(this); 242 | this.mod = null; 243 | this.channels = [ new Channel(this), new Channel(this), new Channel(this), new Channel(this) ]; 244 | this.patternBreak = false; 245 | this.publishRow = false; 246 | this.publishStop = false; 247 | } 248 | 249 | onmessage(e) { 250 | switch (e.data.type) { 251 | case 'play': 252 | this.play(e.data.mod, e.data.sampleRate); 253 | break; 254 | case 'stop': 255 | this.stop(); 256 | break; 257 | case 'resume': 258 | this.resume(); 259 | break; 260 | case 'setRow': 261 | this.setRow(e.data.position, e.data.row); 262 | break; 263 | case 'enableRowSubscription': 264 | this.publishRow = true; 265 | break; 266 | case 'disableRowSubscription': 267 | this.publishRow = false; 268 | break; 269 | case 'enableStopSubscription': 270 | this.publishStop = true; 271 | break; 272 | } 273 | } 274 | 275 | play(mod, sampleRate) { 276 | this.mod = mod; 277 | this.sampleRate = sampleRate; 278 | 279 | this.setBpm(125); 280 | this.setTicksPerRow(6); 281 | 282 | // Start at the last tick of the pattern "before the first pattern" 283 | this.position = -1; 284 | this.rowIndex = 63; 285 | this.tick = 5; 286 | this.ticksPerRow = 6; 287 | 288 | // Immediately move to the first row of the first pattern 289 | this.outputsUntilNextTick = 0; 290 | this.playing = true; 291 | } 292 | 293 | stop() { 294 | this.playing = false; 295 | } 296 | 297 | resume() { 298 | this.playing = true; 299 | } 300 | 301 | setRow(position, row) { 302 | this.rowIndex = row - 1; 303 | if (this.rowIndex == -1) { 304 | this.rowIndex = 63; 305 | this.position = position - 1; 306 | } 307 | else { 308 | this.position = position; 309 | } 310 | this.tick = this.ticksPerRow - 1; 311 | this.outputsUntilNextTick = 0; 312 | this.patternBreak = false; 313 | } 314 | 315 | setTicksPerRow(ticksPerRow) { 316 | this.ticksPerRow = ticksPerRow; 317 | } 318 | 319 | setBpm(bpm) { 320 | this.bpm = bpm; 321 | this.outputsPerTick = this.sampleRate * 60 / this.bpm / 4 / 6; 322 | if ((bpm === 0) && this.publishStop) { 323 | this.port.postMessage({ type: 'stop' }); 324 | } 325 | } 326 | 327 | setPatternBreak(row) { 328 | this.patternBreak = row; 329 | } 330 | 331 | nextRow() { 332 | ++this.rowIndex; 333 | if (this.patternBreak !== false) { 334 | this.rowIndex = this.patternBreak; 335 | ++this.position; 336 | this.patternBreak = false; 337 | } 338 | else if (this.rowIndex == 64) { 339 | this.rowIndex = 0; 340 | ++this.position; 341 | } 342 | 343 | if (this.position >= this.mod.length) { 344 | this.position = 0; 345 | } 346 | 347 | const patternIndex = this.mod.patternTable[this.position]; 348 | const pattern = this.mod.patterns[patternIndex]; 349 | const row = pattern.rows[this.rowIndex]; 350 | if (!row) debugger; 351 | 352 | for (let i = 0; i < 4; ++i) { 353 | this.channels[i].play(row.notes[i]); 354 | } 355 | 356 | if (this.publishRow) { 357 | this.port.postMessage({ 358 | type: 'row', 359 | position: this.position, 360 | rowIndex: this.rowIndex 361 | }); 362 | } 363 | } 364 | 365 | nextTick() { 366 | ++this.tick; 367 | if (this.tick == this.ticksPerRow) { 368 | this.tick = 0; 369 | this.nextRow(); 370 | } 371 | 372 | for (let i = 0; i < 4; ++i) { 373 | this.channels[i].performTick(); 374 | } 375 | } 376 | 377 | nextOutput() { 378 | if (!this.mod) return 0.0; 379 | if (!this.playing) return 0.0; 380 | 381 | if (this.outputsUntilNextTick <= 0) { 382 | this.nextTick(); 383 | this.outputsUntilNextTick += this.outputsPerTick; 384 | } 385 | 386 | this.outputsUntilNextTick--; 387 | 388 | const rawOutput = this.channels.reduce((acc, channel) => acc + channel.nextOutput(), 0.0); 389 | return Math.tanh(rawOutput); 390 | } 391 | 392 | process(inputs, outputs) { 393 | const output = outputs[0]; 394 | const channel = output[0]; 395 | 396 | for (let i = 0; i < channel.length; ++i) { 397 | const value = this.nextOutput(); 398 | channel[i] = value; 399 | } 400 | 401 | return true; 402 | } 403 | } 404 | 405 | registerProcessor('mod-player-worklet', ModPlayerWorklet); 406 | -------------------------------------------------------------------------------- /mod.js: -------------------------------------------------------------------------------- 1 | class Instrument { 2 | constructor(modfile, index, sampleStart) { 3 | const data = new Uint8Array(modfile, 20 + index * 30, 30); 4 | const nameBytes = data.slice(0, 21).filter(a => !!a); 5 | this.name = String.fromCodePoint(...nameBytes).trim(); 6 | this.length = 2 * (data[22] * 256 + data[23]); 7 | this.finetune = data[24]; 8 | if (this.finetune > 7) this.finetune -= 16; 9 | this.volume = data[25]; 10 | this.repeatOffset = 2 * (data[26] * 256 + data[27]); 11 | this.repeatLength = 2 * (data[28] * 256 + data[29]); 12 | this.bytes = new Int8Array(modfile, sampleStart, this.length); 13 | this.isLooped = this.repeatOffset != 0 || this.repeatLength > 2; 14 | } 15 | } 16 | 17 | class Note { 18 | constructor (noteData) { 19 | // The data for each note is bit-packed like this: 20 | // Byte 0 Byte 1 Byte 2 Byte 3 21 | // 76543210 76543210 76543210 76543210 22 | // iiiipppp pppppppp iiiieeee eeeeeeee 23 | // i = instrument index 24 | // p = period 25 | // e = effect 26 | this.instrument = (noteData[0] & 0xf0) | (noteData[2] >> 4); 27 | this.period = (noteData[0] & 0x0f) * 256 + noteData[1]; 28 | let effectId = noteData[2] & 0x0f; 29 | let effectData = noteData[3]; 30 | if (effectId === 0x0e) { 31 | effectId = 0xe0 | (effectData >> 4); 32 | effectData &= 0x0f; 33 | } 34 | this.rawEffect = ((noteData[2] & 0x0f) << 8) | noteData[3]; 35 | this.effectId = effectId; 36 | this.effectData = effectData; 37 | this.effectHigh = effectData >> 4; 38 | this.effectLow = effectData & 0x0f; 39 | this.hasEffect = effectId || effectData; 40 | } 41 | } 42 | 43 | class Row { 44 | constructor(rowData) { 45 | this.notes = []; 46 | 47 | // Each note is 4 bytes 48 | for (let i = 0; i < 16; i += 4) { 49 | const noteData = rowData.slice(i, i + 4); 50 | this.notes.push(new Note(noteData)); 51 | } 52 | } 53 | } 54 | 55 | class Pattern { 56 | constructor(modfile, index) { 57 | // Each pattern is 1024 bytes long 58 | const data = new Uint8Array(modfile, 1084 + index * 1024, 1024); 59 | this.rows = []; 60 | 61 | // Each pattern is made up of 64 rows 62 | for (let i = 0; i < 64; ++i) { 63 | // Each row is made up of 16 bytes 64 | const rowData = data.slice(i * 16, i * 16 + 16); 65 | this.rows.push(new Row(rowData)); 66 | } 67 | } 68 | } 69 | 70 | export class Mod { 71 | constructor(modfile) { 72 | const nameArray = new Uint8Array(modfile, 0, 20); 73 | const nameBytes = nameArray.filter(a => !!a); 74 | this.name = String.fromCodePoint(...nameBytes).trim(); 75 | 76 | // Store the song length 77 | this.length = new Uint8Array(modfile, 950, 1)[0]; 78 | 79 | // Store the pattern table 80 | this.patternTable = new Uint8Array(modfile, 952, this.length); 81 | 82 | // Find the highest pattern number 83 | const maxPatternIndex = Math.max(...this.patternTable); 84 | 85 | // Extract all instruments 86 | this.instruments = [null]; 87 | let sampleStart = 1084 + (maxPatternIndex + 1) * 1024; 88 | for (let i = 0; i < 31; ++i) { 89 | const instr = new Instrument(modfile, i, sampleStart); 90 | this.instruments.push(instr); 91 | sampleStart += instr.length; 92 | } 93 | 94 | // Extract the pattern data 95 | this.patterns = []; 96 | for (let i = 0; i <= maxPatternIndex; ++i) { 97 | const pattern = new Pattern(modfile, i); 98 | this.patterns.push(pattern); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /player.js: -------------------------------------------------------------------------------- 1 | import { loadMod } from './loader.js'; 2 | 3 | const AUDIO = Symbol('audio'); 4 | const GAIN = Symbol('gain'); 5 | const WORKLET = Symbol('worklet'); 6 | const ROW_CALLBACKS = Symbol('rowCallbacks'); 7 | const SINGLE_CALLBACKS = Symbol('singleCallbacks'); 8 | const STOP_CALLBACKS = Symbol('stopCallbacks'); 9 | 10 | export class ModPlayer { 11 | constructor(audioContext) { 12 | this.mod = null; 13 | this.playing = false; 14 | this[AUDIO] = audioContext || null; 15 | this[GAIN] = null; 16 | this[WORKLET] = null; 17 | this[ROW_CALLBACKS] = []; 18 | this[SINGLE_CALLBACKS] = { }; 19 | this[STOP_CALLBACKS] = []; 20 | } 21 | 22 | async load(url) { 23 | if (this[WORKLET]) this.unload(); 24 | 25 | this.mod = await loadMod(url); 26 | if (!this[AUDIO]) this[AUDIO] = new AudioContext(); 27 | this[GAIN] = this[AUDIO].createGain(); 28 | this[GAIN].gain.value = 0.3; 29 | await this[AUDIO].audioWorklet.addModule('./mod-player-worklet.js'); 30 | this[WORKLET] = new AudioWorkletNode(this[AUDIO], 'mod-player-worklet'); 31 | this[WORKLET].connect(this[GAIN]).connect(this[AUDIO].destination); 32 | 33 | this[WORKLET].port.onmessage = this.onmessage.bind(this); 34 | } 35 | 36 | onmessage(event) { 37 | const { data } = event; 38 | switch (data.type) { 39 | case 'row': 40 | // Call all the general row callbacks 41 | for (let callback of this[ROW_CALLBACKS]) { 42 | callback(data.position, data.rowIndex); 43 | } 44 | 45 | // Call all the single row callbacks 46 | const key = data.position + ':' + data.rowIndex; 47 | if (key in this[SINGLE_CALLBACKS]) { 48 | for (let callback of this[SINGLE_CALLBACKS][key]) { 49 | callback(data.position, data.rowIndex); 50 | } 51 | } 52 | break; 53 | case 'stop': 54 | for (let callback of this[STOP_CALLBACKS]) { 55 | callback(); 56 | } 57 | break; 58 | } 59 | } 60 | 61 | watchRows(callback) { 62 | this[WORKLET].port.postMessage({ 63 | type: 'enableRowSubscription' 64 | }); 65 | this[ROW_CALLBACKS].push(callback); 66 | } 67 | 68 | watch(position, row, callback) { 69 | this[WORKLET].port.postMessage({ 70 | type: 'enableRowSubscription' 71 | }); 72 | 73 | // Store the callback in a dictionary 74 | const key = position + ':' + row; 75 | 76 | // There can be multiple callbacks for the same position and row 77 | // so we store them in an array 78 | if (!(key in this[SINGLE_CALLBACKS])) { 79 | this[SINGLE_CALLBACKS][key] = []; 80 | } 81 | 82 | // Add the callback to the array 83 | this[SINGLE_CALLBACKS][key].push(callback); 84 | } 85 | 86 | watchStop(callback) { 87 | this[WORKLET].port.postMessage({ 88 | type: 'enableStopSubscription' 89 | }); 90 | this[STOP_CALLBACKS].push(callback); 91 | } 92 | 93 | unload() { 94 | if (this.playing) this.stop(); 95 | if (!this[WORKLET]) return; 96 | 97 | this[WORKLET].disconnect(); 98 | this[AUDIO].close(); 99 | 100 | this.mod = null; 101 | this[AUDIO] = null; 102 | this[WORKLET] = null; 103 | this[ROW_CALLBACKS] = []; 104 | this[SINGLE_CALLBACKS] = { }; 105 | } 106 | 107 | play() { 108 | if (this.playing) return; 109 | if (!this[WORKLET]) return; 110 | 111 | this[AUDIO].resume(); 112 | this[WORKLET].port.postMessage({ 113 | type: 'play', 114 | mod: this.mod, 115 | sampleRate: this[AUDIO].sampleRate 116 | }); 117 | 118 | this.playing = true; 119 | } 120 | 121 | stop() { 122 | if (!this.playing) return; 123 | 124 | this[WORKLET].port.postMessage({ 125 | type: 'stop' 126 | }); 127 | 128 | this.playing = false; 129 | } 130 | 131 | resume() { 132 | if (this.playing) return; 133 | 134 | this[WORKLET].port.postMessage({ 135 | type: 'resume' 136 | }); 137 | 138 | this.playing = true; 139 | } 140 | 141 | setRow(position, row) { 142 | this[WORKLET].port.postMessage({ 143 | type: 'setRow', 144 | position: position, 145 | row: row 146 | }); 147 | } 148 | 149 | setVolume(volume) { 150 | this[GAIN].gain.value = volume; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --window-background: #f1f1f1; 3 | --main-background: #ffffff; 4 | --main-color: #0e0e0e; 5 | --button-background: #658d4a; 6 | --button-hover: #4a6935; 7 | --button-text: #ffffff; 8 | --input-background: #ffffff; 9 | --border: rgba(0, 0, 0, 0.5); 10 | } 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --window-background: #0f0f0f; 14 | --main-background: #000000; 15 | --main-color: #ffffff; 16 | --button-background: #8ab66d; 17 | --button-hover: #addc8d; 18 | --button-text: #000000; 19 | --input-background: #999999; 20 | --border: rgba(255, 255, 255, 0.5); 21 | } 22 | } 23 | 24 | * { 25 | margin: 0; 26 | padding: 0; 27 | font-size: inherit; 28 | font-weight: inherit; 29 | font-style: inherit; 30 | font-variant-numeric: tabular-nums; 31 | color: inherit; 32 | border: 0 none; 33 | line-height: inherit; 34 | font-family: inherit; 35 | box-sizing: border-box; 36 | } 37 | html { 38 | font-size: 16px; 39 | font-family: "Barlow", "Arial", "Helvetica", sans-serif; 40 | line-height: 1.4; 41 | background-color: var(--window-background); 42 | color: var(--main-color); 43 | display: block; 44 | } 45 | body { 46 | display: flex; 47 | flex-direction: column; 48 | justify-content: flex-start; 49 | align-items: stretch; 50 | } 51 | header { 52 | background-color: var(--main-background); 53 | padding: 1rem; 54 | display: flex; 55 | flex-direction: column; 56 | align-items: center; 57 | } 58 | header > * { 59 | width: 80%; 60 | } 61 | main { 62 | background-color: var(--main-background); 63 | padding: 1rem; 64 | display: flex; 65 | flex-direction: column; 66 | align-items: center; 67 | border-top: 1px solid var(--border); 68 | } 69 | footer { 70 | background-color: var(--main-background); 71 | padding: 1rem; 72 | display: flex; 73 | flex-direction: column; 74 | align-items: center; 75 | border-top: 1px solid var(--border); 76 | margin-bottom: 1rem; 77 | } 78 | main > * { 79 | width: 80%; 80 | } 81 | h1 { 82 | font-size: 2.5rem; 83 | font-family: 'Playfair Display', Georgia, serif; 84 | font-weight: 700; 85 | line-height: 1.2; 86 | margin-bottom: 1rem; 87 | } 88 | h2 { 89 | font-size: 2rem; 90 | font-family: 'Playfair Display', Georgia, serif; 91 | font-weight: 700; 92 | line-height: 1.2; 93 | margin-bottom: 1rem; 94 | } 95 | h1 + p, h2 + p, p + p { 96 | margin-top: 1rem; 97 | } 98 | 99 | button { 100 | border: 1px solid var(--border); 101 | padding: 0.2rem 0.5rem; 102 | cursor: pointer; 103 | border-radius: 2px; 104 | background-color: var(--button-background); 105 | color: var(--button-text); 106 | } 107 | button:hover { 108 | background-color: var(--button-hover); 109 | } 110 | input[type=number] { 111 | border: 1px solid var(--border); 112 | padding: 0.2rem 0.5rem; 113 | background-color: var(--input-background); 114 | color: #000; 115 | } --------------------------------------------------------------------------------