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