diff --git a/README.md b/README.md index aeb14c1..0009cf1 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,21 @@ For a demo, head over [here](https://warpdesign.github.io/modplayer-js/). modplayer-js requires a browser that supports the [ScriptProcessNode API](https://developer.mozilla.org/en-US/docs/Web/API/ScriptProcessorNode) and will make use of the new [AudioWorklet API](https://developers.google.com/web/updates/2017/12/audio-worklet]) if it's detected (as I'm publishing it only Chrome supports it). -modplayer-js has been tested on: +The native `AudioWorklet` API is used in these browsers: - - Safari 11 (OSX & iOS) - - Firefox - - Chrome - - Edge + - Chrome 70 (OSX, Windows) + +Modplayer-js will fall back to the deprecated `ScriptProcessorNode` API in these browsers: + + - Safari 11.1.2 (OSX & iOS) + - Firefox 62.0 (OSX) + - Edge 42.17134.4071.0 (Xbox One, stuttering audio) + - Edge 42 17134.1.0 (Windows 10, stuttering audio) # What's implemented - Amiga 4 channel Sountracker/Noisetracker mod files with 4 channels and 15-31 instruments +- Stereo playback (channels 0 & 3 goes to the left chan, 1 & 2 to the right, just like on a real Amiga) - LowPass filter (not sure it sounds right) - Left/Right Spectrum vizualizers - Ability to mute any of the 4 module channels @@ -31,8 +36,9 @@ Most note effects should be supported, including extended ones. Only effect not ModPlayer JS makes use of the following piece of software: - The User Interface is built using [Material Design Lite](https://getmdl.io) - - The [AudioWorklet polyfill](https://github.com/GoogleChromeLabs/audioworklet-polyfill) is used to stay compatible with browsers that do not support it yet + - The [AudioWorklet polyfill](https://github.com/GoogleChromeLabs/audioworklet-polyfill) is used to stay compatible with browsers that do not support the audio render thread API - Spectrum display is based on [Audio DSP Background](https://github.com/acarabott/audio-dsp-playground) by [@Acarabott](https://github.com/acarabott) + - get-float-time-domain-data was written by [github.com/mohayonao](https://github.com/mohayonao/get-float-time-domain-data) (needed for Safari) I also heavily used [MilkyTracker](https://milkytracker.titandemo.org/) and [webaudio-mod-player](https://mod.haxor.fi/) - which plays lot of module formats with high fidelity - to track down some timing bugs. diff --git a/index.html b/index.html index 0b3ac0a..8a08a14 100644 --- a/index.html +++ b/index.html @@ -3,243 +3,13 @@ + + + + - + @@ -294,7 +64,6 @@ } - diff --git a/js/get-float-time-domain-data.min.js b/js/get-float-time-domain-data.min.js new file mode 100644 index 0000000..b04e058 --- /dev/null +++ b/js/get-float-time-domain-data.min.js @@ -0,0 +1 @@ +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o { + toast.show(`Module loaded: ${moduleList[selectedMod].file}`); + + const samples = event.data.samples; + let str = ''; + for (let i = 0; i < samples.length; ++i) { + if (samples[i].name.length) { + str += `
  • ${samples[i].name}
  • `; + } + } + + document.querySelector('.sample-list').innerHTML = str; + + document.querySelector('.song-title').innerText = event.data.title; + document.querySelector('.title').innerText = moduleList[selectedMod].file; + document.querySelector('.author').innerText = moduleList[selectedMod].author; + document.querySelector('.song-length').innerText = event.data.length; + document.querySelector('.song-samples').innerText = event.data.samples.length; + document.querySelector('.song-positions').innerText = event.data.positions; + document.querySelector('.song-patterns').innerText = event.data.patterns; + + document.querySelector('#loader').classList.remove('is-active'); + + document.querySelectorAll('.controls button').forEach((button) => { + button.style.display = 'inline-block'; + }); + + togglePlayButton(); + + if (event.data.wasPlaying) { + togglePlay(); + } + }); + + var modNav = document.querySelector('.nav-module'), + options = ''; + + moduleList.forEach((module, i) => { + options += `${module.file}`; + }); + + modNav.innerHTML = options; + + componentHandler.upgradeDom(); + + document.addEventListener('keyup', (e) => e.keyCode === 32 && togglePlay()); + + document.querySelector('.mdl-card__title-text').addEventListener('click', () => { + document.querySelector('.mdl-layout__obfuscator').click(); + }); + + document.addEventListener('analyzer_ready', (event) => { + requestAnimationFrame(() => { + effects[effect](event.data); + }); + }); + + document.querySelector('.channel_control').addEventListener('click', (event) => { + if (event.target.id && event.target.id.match(/channel-toggle/)) { + var channel = event.target.id.substr(-1, 1), + checked = event.target.hasAttribute('checked'); + + channelsPlaying[channel - 1] = !channelsPlaying[channel - 1]; + + ModPlayer.setPlayingChannels(channelsPlaying); + } + }); + + canvas.onclick = () => { + effect++; + if (effect >= effects.length) { + effect = 0; + } + }; + + function drawBars(amplitudeArray) { + var bufferLength = amplitudeArray.length; + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + + var barWidth = (canvasWidth / bufferLength) * 2.5 - 1; + barWidth *= 2; + var barHeight; + var x = 0; + + for (var i = 0; i < bufferLength; i++) { + barHeight = amplitudeArray[i]; + + ctx.fillStyle = 'rgb(' + (barHeight + 100) + ',50,50)'; + ctx.fillRect(x, canvasHeight - barHeight / 2, barWidth, barHeight / 2); + + x += barWidth; + } + } + + function drawOscillo(amplitudeArray) { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + for (var i = 0; i < amplitudeArray.length; i++) { + var value = amplitudeArray[i] / 256; + var y = canvasHeight - (canvasHeight * value) - 1; + ctx.fillStyle = '#000000'; + ctx.fillRect(i, y, 1, 1); + } + } + + function drawOscillo2(amplitudeArray) { + var bufferLength = amplitudeArray.length; + + ctx.fillStyle = "rgb(200, 200, 200)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.lineWidth = 2; + ctx.strokeStyle = "rgb(0, 0, 0)"; + + ctx.beginPath(); + + var sliceWidth = canvas.width * 1.0 / bufferLength; + var x = 0; + + for (var i = 0; i < bufferLength; i++) { + + var v = amplitudeArray[i] / 128.0; + var y = v * canvas.height / 2; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + + x += sliceWidth; + } + + ctx.lineTo(canvas.width, canvas.height / 2); + ctx.stroke(); + } + + effects.push(drawBars, drawOscillo); + + ModPlayer.init({ + canvas: canvas + }).then(() => { + loadModule(selectedMod, false); + }).catch((err) => { + toast.show(`Error loading module: ${err}`); + }); +} + +function togglePlayButton() { + document.querySelector('button.play i').innerText = ModPlayer.playing && 'pause' || 'play_arrow'; +} + +function togglePlay() { + ModPlayer.play(); + togglePlayButton(); +} + +function stop() { + ModPlayer.stop(); + togglePlayButton(); +} + +function loadModule(moduleIndex, hideDrawer = true) { + var moduleName = moduleList[moduleIndex].file; + + selectedMod = moduleIndex; + + if (ModPlayer.ready && moduleName) { + // I guess that's the best way to programmatically hide the drawer + // since MDL does not provide any API to do that + if (hideDrawer) { + document.querySelector('.mdl-layout__obfuscator').click(); + } + + document.querySelector('#loader').classList.add('is-active'); + + document.querySelectorAll('.controls button').forEach((button) => { + button.style.display = 'none'; + }); + + document.querySelector('a.mdl-navigation__link.selected').classList.toggle('selected'); + document.querySelector(`a.mdl-navigation__link.mod_${moduleIndex}`).classList.add('selected'); + + ModPlayer.loadModule(prefix + moduleName) + .catch(err => { + toast.show(`Error loading module: ${err}`); + }); + } +} \ No newline at end of file diff --git a/js/mod-processor-es5.js b/js/mod-processor-es5.js new file mode 100644 index 0000000..dfcd186 --- /dev/null +++ b/js/mod-processor-es5.js @@ -0,0 +1,2 @@ +var t=function(t,e,s){void 0===s&&(s=0);for(var i=new Uint8Array(t),a="",o=!1,n=0;!o;++n){var r=i[s+n];(o=0===r)||(a+=String.fromCharCode(r))}return a},e=function(t,e,s){return void 0===e&&(e=0),void 0===s&&(s=!1),new DataView(t).getUint16(e,s)},s=function(s){function a(){s.call(this),this.port.onmessage=this.handleMessage.bind(this),this.patternOffset=1084,this.patternLength=1024}return s&&(a.__proto__=s),(a.prototype=Object.create(s&&s.prototype)).constructor=a,a.prototype.handleMessage=function(t){var e=this;switch(t.data.message){case"init":this.mixingRate=t.data.mixingRate;break;case"loadModule":this.prepareModule(t.data.buffer);break;case"setPlay":this.ready&&(this.playing=t.data.playing);break;case"reset":this.ready&&this.resetValues();break;case"setPlayingChannels":console.log(t.data.channels),t.data.channels.forEach(function(t,s){var i=e.channels[s];i&&(i.off=!t)})}},a.prototype.postMessage=function(t){this.port.postMessage(t)},a.prototype.process=function(t,e,s){return this.ready&&this.playing?this.mix(e[0]):this.emptyOutputBuffer(e[0]),!0},a.prototype.emptyOutputBuffer=function(t){for(var e=t[0].length,s=t.length,i=0;i-1&&!o.done&&this.ticks>=o.delay){var n=this.samples[o.sample];t[i][s]+=n.data[Math.floor(o.samplePos)]*o.volume/64,o.samplePos+=7093789.2/(2*o.period*this.mixingRate),o.done||(n.repeatLength||n.repeatStart?o.samplePos>=n.repeatStart+n.repeatLength&&(o.samplePos=n.repeatStart):o.samplePos>n.length&&(o.samplePos=0,o.done=!0))}}this.filledSamples++,this.newTick=!1}},a.prototype.tick=function(){this.filledSamples>this.samplesPerTick&&(this.newTick=!0,this.ticks++,this.filledSamples=0,this.ticks>this.speed-1&&(this.ticks=0,this.rowRepeat<=0&&this.row++,(this.row>63||this.skipPattern)&&(this.skipPattern=!1,this.jumpPattern>-1?(this.position=this.jumpPattern,this.jumpPattern=-1,this.getNextPattern()):this.getNextPattern(!0)),this.rowJump>-1&&(this.row=this.rowJump,this.rowJump=-1),this.row>63&&(this.row=0),this.decodeRow(),console.log("** next row !",this.row.toString(16).padStart(2,"0"))))},a.prototype.getNextPattern=function(t){t&&this.position++,this.position>this.positions.length-1&&(console.log("Warning: last position reached, going back to 0"),this.position=0),this.pattern=this.positions[this.position],console.log("** position",this.position,"pattern:",this.pattern)},a.prototype.decodeRow=function(){this.started||(this.started=!0,this.getNextPattern());for(var t=new Uint8Array(this.patterns[this.pattern],16*this.row,16),e=0;e>4)-1,o=15&t[2+s],n=t[3+s],r=this.channels[e];r.delay=0,o?14===o?(r.cmd=224+(n>>4),r.data=15&n):(r.cmd=o,r.data=n):r.cmd=0,a>-1&&(3!==r.cmd&&5!==r.cmd&&(r.samplePos=0),r.done=!1,r.sample=a,r.volume=this.samples[a].volume),i&&(r.done=!1,3!==r.cmd&&5!==r.cmd?(r.period=i,r.samplePos=0):r.slideTo=i)}},a.prototype.executeEffect=function(t){try{i[t.cmd](this,t)}catch(e){console.warn("effect not implemented: "+t.cmd.toString(16).padStart(2,"0")+"/"+t.data.toString(16).padStart(2,"0"))}},a.prototype.getInstruments=function(){this.detectMaxSamples(),this.samples=new Array;for(var s=20,i=new Uint8Array(this.buffer),a=0;ao.length&&(o.repeatLength=0,o.repeatStart=0),this.samples.push(o),s+=30}},a.prototype.getPatternData=function(){var t=new Uint8Array(this.buffer,950);this.songLength=t[0];for(var e=2,s=0,i=0;is&&(s=a)}e=this.patternOffset;for(var o=0;o<=s;++o)this.patterns.push(this.buffer.slice(e,e+this.patternLength)),e+=this.patternLength},a.prototype.getSampleData=function(){for(var t=this.patternOffset+this.patterns.length*this.patternLength,e=0;e856&&(e.period=856))},3:function(t,e,s){t.ticks?e.slideTo&&t.ticks?e.periode.slideTo&&(e.period=e.slideTo)):e.period>e.slideTo&&(e.period-=e.slideSpeed,e.period>4;i&&s&&(e.vdepth=s,e.vspeed=i)}},5:function(t,e){this[3](t,e),this[10](t,e)},6:function(t,e){this[4](t,e),this[10](t,e)},9:function(t,e){t.ticks||(e.samplePos=256*e.data)},10:function(t,e){if(t.ticks){var s=e.data>>4,i=15&e.data;i?s||(e.volume-=i):e.volume+=s,e.volume>63?e.volume=63:e.volume<0&&(e.volume=0)}},11:function(t,e){e.data>=0&&e.data<=t.patterns.length-1&&(t.skipPattern=!0,t.jumpPattern=e.data,t.rowJump=0)},12:function(t,e){t.ticks||(e.volume=e.data,e.volume>63&&(e.volume=63))},13:function(t,e){t.ticks||(t.rowJump=10*((240&e.data)>>4)+(15&e.data),t.skipPattern=!0)},224:function(t,e){t.ticks||(console.log("need to toggle lowPass",!!e.data),t.toggleLowPass(!!e.data))},230:function(t,e){0===e.data?e.loopCount&&(e.loopStart=t.row,e.loopCount--,e.loopCount||(e.loopDone=!0)):e.loopDone||(e.loopCount||(e.loopCount=e.data),e.rowJump=e.loopStart)},233:function(t,e){(t.ticks+1)%e.data||(console.log("retriggering note!",t.ticks+1),e.samplePos=0,e.done=!1)},234:function(t,e){t.ticks||(e.volume+=e.data,e.volume>63&&(e.volume=63))},237:function(t,e){t.ticks||(e.delay=e.data)},238:function(t,e){t.ticks||(t.rowRepeat?t.rowRepeat&&t.rowRepeat--:(t.rowRepeat=e.data,console.log("setting repeat to",t.rowRepeat)))},15:function(t,e){t.ticks||(e.data<32?t.speed=e.data:(t.bpm=e.data,t.calcTickSpeed()))}}; +//# sourceMappingURL=modplayer-js.js.map diff --git a/js/modplayer.js b/js/modplayer.js index fa43ae2..3ce7aea 100644 --- a/js/modplayer.js +++ b/js/modplayer.js @@ -8,6 +8,7 @@ const ModPlayer = { bufferFull: false, ready: true, loaded: false, + isXbox: !!navigator.userAgent.match(/Xbox One/), init(options) { this.canvas = options.canvas; this.ctx = this.canvas.getContext('2d'); @@ -55,7 +56,9 @@ const ModPlayer = { this.mixingRate = this.context.sampleRate; - return this.context.audioWorklet.addModule('js/mod-processor.js').then(() => { + const soundProcessor = this.isXbox && 'mod-processor-es5.js' || 'mod-processor.js'; + + return this.context.audioWorklet.addModule(`js/${soundProcessor}`).then(() => { this.workletNode = new AudioWorkletNode(this.context, 'mod-processor', { outputChannelCount:[2] }); diff --git a/js/utils.js b/js/utils.js index 7869420..c4e6acb 100644 --- a/js/utils.js +++ b/js/utils.js @@ -29,4 +29,29 @@ class Deferred { static resolve(val) { return Promise.resolve(val); } +} + +/** + * Simple class to manage Material Lite Toast messages + */ +class Toast { + /** + * + * @param {String} id id of the element to use in the DOM + */ + constructor(id) { + this.container = document.getElementById(id); + } + + /** + * + * @param {String} message text to display in the toast + * @param {Number=2750} timeout timeout in ms + */ + show(message, timeout = 2750) { + this.container.MaterialSnackbar.showSnackbar({ + timeout: timeout, + message: message + }); + } } \ No newline at end of file