diff --git a/.eslintrc b/.eslintrc index 6c83c5aa..c0119863 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,11 +7,7 @@ "experimentalObjectRestSpread": true } }, - "plugins": [ - "react", - "prettier", - "import" - ], + "plugins": ["react", "prettier", "import"], "settings": { "react": { "version": "15.2" @@ -33,21 +29,12 @@ "maxBOF": 0 } ], - "arrow-body-style": [ - "error", - "as-needed" - ], + "arrow-body-style": ["error", "as-needed"], "arrow-spacing": "error", "block-scoped-var": "warn", - "brace-style": [ - "warn", - "1tbs" - ], + "brace-style": ["warn", "1tbs"], "camelcase": "error", - "comma-dangle": [ - "error", - "never" - ], + "comma-dangle": ["error", "never"], "comma-spacing": "error", "consistent-return": "warn", "constructor-super": "error", @@ -58,10 +45,7 @@ } ], "eol-last": "error", - "eqeqeq": [ - "error", - "smart" - ], + "eqeqeq": ["error", "smart"], "guard-for-in": "error", "indent": [ "error", @@ -70,21 +54,12 @@ "SwitchCase": 1 } ], - "jsx-quotes": [ - "error", - "prefer-double" - ], + "jsx-quotes": ["error", "prefer-double"], "key-spacing": "warn", "keyword-spacing": "error", "linebreak-style": "error", - "max-depth": [ - "warn", - 4 - ], - "max-params": [ - "warn", - 5 - ], + "max-depth": ["warn", 4], + "max-params": ["warn", 5], "new-cap": "error", "no-alert": "error", "no-caller": "error", @@ -139,26 +114,16 @@ "no-unreachable": "error", "no-unused-expressions": "error", "no-unused-vars": "error", - "no-use-before-define": [ - "error", - "nofunc" - ], + "no-use-before-define": ["error", "nofunc"], "no-useless-rename": "error", "no-var": "error", "no-with": "error", - "object-curly-spacing": [ - "error", - "always" - ], + "object-curly-spacing": ["error", "always"], "prefer-arrow-callback": "warn", "prefer-const": "error", "prefer-spread": "error", "prefer-template": "warn", - "quotes": [ - "error", - "double", - "avoid-escape" - ], + "quotes": ["error", "double", "avoid-escape"], "radix": "error", "react/no-string-refs": "error", "react/jsx-boolean-value": "error", @@ -188,7 +153,6 @@ "template-curly-spacing": "error", "use-isnan": "error", "valid-typeof": "error", - "wrap-iife": "error", "prettier/prettier": "error", "import/no-unresolved": "error", "import/default": "error", diff --git a/experiments/audioAbstraction/app.js b/experiments/audioAbstraction/app.js new file mode 100644 index 00000000..f40902ff --- /dev/null +++ b/experiments/audioAbstraction/app.js @@ -0,0 +1,73 @@ +/* global ElementSource BufferSource */ +const play = document.getElementById("play"); +const pause = document.getElementById("pause"); +const stop = document.getElementById("stop"); +const eject = document.getElementById("eject"); +const url = document.getElementById("url"); +const loadUrl = document.getElementById("loadUrl"); +const info = document.getElementById("info"); +const position = document.getElementById("position"); +const sourceType = document.getElementById("sourceType"); + +const context = new (window.AudioContext || window.webkitAudioContext)(); + +const getSourceClass = () => + sourceType.value === "buffer" ? BufferSource : ElementSource; + +let source = new (getSourceClass())(context, context.destination); +source.setAutoPlay(true); + +sourceType.addEventListener("change", () => { + source.disconnect(); + source = new (getSourceClass())(context, context.destination); + source.setAutoPlay(true); +}); + +//const source = new BufferSource(context, context.destination); + +play.addEventListener("click", () => { + source.play(); +}); +pause.addEventListener("click", () => { + source.pause(); +}); +stop.addEventListener("click", () => { + source.stop(); +}); +eject.addEventListener("change", e => { + source.loadFile(e.target.files[0]); +}); + +loadUrl.addEventListener("click", () => { + source.loadUrl(url.value); +}); + +position.addEventListener("change", () => { + const percent = position.value / 100; + const newTime = source.getDuration() * percent; + source.seekToTime(newTime); +}); + +const updateInfo = () => { + const data = { + numberOfChannels: source.getNumberOfChannels(), + status: source.getStatus(), + duration: source.getDuration(), + timeElapsed: source.getTimeElapsed(), + sampleRate: source.getSampleRate(), + loop: source.getLoop() + }; + + info.value = JSON.stringify(data, null, 2); +}; +info.addEventListener("click", updateInfo); + +source.on("statusChange", () => { + updateInfo(); +}); + +source.on("positionChange", () => { + const percent = source.getTimeElapsed() / source.getDuration(); + position.value = percent * 100; + updateInfo(); +}); diff --git a/experiments/audioAbstraction/bufferSource.js b/experiments/audioAbstraction/bufferSource.js new file mode 100644 index 00000000..ae45cefc --- /dev/null +++ b/experiments/audioAbstraction/bufferSource.js @@ -0,0 +1,252 @@ +(function() { + const invariant = (assertion, message) => { + if (!assertion) { + console.error(message); + } + }; + + async function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function(e) { + const arrayBuffer = e.target.result; + resolve(arrayBuffer); + }; + reader.onerror = function(e) { + reject(e); + }; + reader.readAsArrayBuffer(file); + }); + } + + async function readUrlAsArrayBuffer(url) { + return new Promise((resolve, reject) => { + const oReq = new XMLHttpRequest(); + oReq.open("GET", url, true); + oReq.responseType = "arraybuffer"; + + oReq.onload = () => { + const arrayBuffer = oReq.response; // Note: not oReq.responseText + resolve(arrayBuffer); + }; + + oReq.onerror = e => { + reject(e); + }; + + oReq.send(null); + return true; + }); + } + + const STATUS = { + WAITING: "WAITING", + PLAYING: "PLAYING", + STOPPED: "STOPPED", + PAUSED: "PAUSED" + }; + + class BufferSource extends window.Emitter { + constructor(context, destination) { + super(); + this._context = context; + this._destination = destination; + this._loop = false; + this._buffer = null; + this._source = null; + this._status = STATUS.STOPPED; + this._startTime = null; + this._autoPlay = false; + this._pausedAtTime = null; + this._onEnded = this._onEnded.bind(this); + } + + disconnect() { + if (this._source != null) { + this._source.disconnect(); + } + } + + // Just adapt the callback into a promise. + _decodeAudioData(buffer) { + return new Promise((resolve, reject) => { + this._context.decodeAudioData(buffer, resolve, reject); + }); + } + + async loadFile(file) { + this._setStatus(STATUS.WAITING); + const arrayBuffer = await readFileAsArrayBuffer(file); + this._buffer = await this._decodeAudioData(arrayBuffer); + if (this._autoPlay) { + this.play(); + } + } + + async loadUrl(url) { + this._setStatus(STATUS.WAITING); + const arrayBuffer = await readUrlAsArrayBuffer(url); + this._buffer = await this._decodeAudioData(arrayBuffer); + if (this._autoPlay) { + this.play(); + } + } + + play() { + switch (this._status) { + case STATUS.WAITING: + case STATUS.PLAYING: + case STATUS.STOPPED: + this._start(0); + break; + case STATUS.PAUSED: + invariant( + this._pausedAtTime != null, + "Status is paused, but there is no _pausedAtTime" + ); + this._start(this._pausedAtTime); + break; + } + } + + pause() { + this._pausedAtTime = this.getTimeElapsed(); + this._silence(STATUS.PAUSED); + } + + stop() { + this._silence(STATUS.STOPPED); + } + + seekToTime(time) { + time = Math.min(time, this.getDuration()); + time = Math.max(time, 0); + this._start(time); + } + + getStatus() { + return this._status; + } + + getDuration() { + if (this._buffer == null) { + return 0; + } + return this._buffer.duration; + } + + getTimeElapsed() { + switch (this._status) { + case STATUS.PLAYING: + invariant( + this._startTime != null, + "status is playing, but there is no _startTime" + ); + return this._context.currentTime - this._startTime; + case STATUS.STOPPED: + case STATUS.WAITING: + return 0; + case STATUS.PAUSED: + invariant( + this._pausedAtTime != null, + "Status is paused, but there is no _pausedAtTime" + ); + return this._pausedAtTime; + } + throw new Error("Invalid status"); + } + + getNumberOfChannels() { + if (this._buffer == null) { + return null; + } + return this._buffer.numberOfChannels; + } + + getSampleRate() { + if (this._buffer == null) { + return null; + } + return this._buffer.sampleRate; + } + + getLoop() { + return this._loop; + } + + setLoop(shouldLoop) { + this._loop = shouldLoop; + if (this._source) { + this._source.loop = this._loop; + } + } + + setAutoPlay(shouldAutoPlay) { + this._autoPlay = shouldAutoPlay; + } + + _start(position) { + if (this._buffer == null) { + return; + } + if (this._source) { + // Does this work? + this._source.disconnect(); + this._source = null; + } + this._source = this._context.createBufferSource(); + this._source.buffer = this._buffer; + this._source.loop = this._loop; + this._source.connect(this._destination); + this._source.onended = this._onEnded; + this._startTime = this._context.currentTime - position; + this._setStatus(STATUS.PLAYING); + this._source.start(0, position); + } + + _silence(reason) { + if (!reason) { + throw new Error("Tried to silence the audio without a reason"); + } + // We must always update the status before ending the audio, so that onEnded + // can distinguish between audio that got to the end of the track, and audio + // that the user stopped/paused + this._setStatus(reason); + if (this._source == null) { + return; + } + this._source.stop(0); + this._source = null; + } + + _startMonitoringPosition() { + this._nextPositionUpdate = setTimeout(() => { + this.trigger("positionChange"); + this._startMonitoringPosition(); + }, 100); + } + + _stopMonitoringPosition() { + clearTimeout(this._nextPositionUpdate); + } + + _setStatus(status) { + this._status = status; + if (status === STATUS.PLAYING) { + this._startMonitoringPosition(); + } else { + this._stopMonitoringPosition(); + } + this.trigger("statusChange"); + } + + _onEnded() { + if (this._status === STATUS.PLAYING) { + this._setStatus(STATUS.STOPPED); + } + } + } + + window.BufferSource = BufferSource; + //export default BufferSource; +})(); diff --git a/experiments/audioAbstraction/elementSource.js b/experiments/audioAbstraction/elementSource.js new file mode 100644 index 00000000..aced96e8 --- /dev/null +++ b/experiments/audioAbstraction/elementSource.js @@ -0,0 +1,120 @@ +(function() { + const STATUS = { + WAITING: "WAITING", + PLAYING: "PLAYING", + STOPPED: "STOPPED", + PAUSED: "PAUSED" + }; + + class ElementSource extends window.Emitter { + constructor(context, destination) { + super(); + this._context = context; + this._destination = destination; + this._audio = document.createElement("audio"); + this._audio.crossorigin = "anonymous"; + + this._audio.addEventListener("loadend", () => { + if (this._autoPlay) { + this.play(); + } + }); + + this._audio.addEventListener("timeupdate", () => { + this.trigger("positionChange"); + }); + + this._source = this._context.createMediaElementSource(this._audio); + this._loop = true; + this._autoPlay = true; // Do this ourselves for now. The actual API is bonkers and may not play well with mobile Safari + this._source.connect(destination); + } + + disconnect() { + this._source.disconnect(); + } + + // Async for now, for compatibility with BufferAudioSource + async loadUrl(url) { + this._audio.src = url; + if (this._autoPlay) { + this.play(); + } + } + + loadFile(fileReference) { + this.loadUrl(URL.createObjectURL(fileReference)); + } + + async play() { + // TODO: set status waiting + if (this._status !== STATUS.PAUSED) { + this.seekToTime(0); + } + await this._audio.play(); + this._setStatus(STATUS.PLAYING); + // TODO: set status playing + } + + pause() { + this._audio.pause(); + this._setStatus(STATUS.PAUSED); + } + + stop() { + this._audio.pause(); + this._audio.currentTime = 0; + this._setStatus(STATUS.STOPPED); + } + + seekToTime(time) { + // Make sure we are within range + // TODO: Use clamp + time = Math.min(time, this.getDuration()); + time = Math.max(time, 0); + this._audio.currentTime = time; + this.trigger("positionChange"); + } + + getStatus() { + return this._status; + } + + getDuration() { + const { duration } = this._audio; + return isNaN(duration) ? 0 : duration; + } + + getTimeElapsed() { + return this._audio.currentTime; + } + + getNumberOfChannels() { + return this._source.channelCount; + } + + getSampleRate() { + return null; + } + + getLoop() { + return this._loop; + } + + setLoop(shouldLoop) { + this._loop = shouldLoop; + } + + setAutoPlay(shouldAutoPlay) { + this._autoPlay = shouldAutoPlay; + } + + _setStatus(status) { + this._status = status; + this.trigger("statusChange"); + } + } + + window.ElementSource = ElementSource; + //export default AudioSource; +})(); diff --git a/experiments/audioAbstraction/emitter.js b/experiments/audioAbstraction/emitter.js new file mode 100644 index 00000000..8dc676e8 --- /dev/null +++ b/experiments/audioAbstraction/emitter.js @@ -0,0 +1,27 @@ +(function() { + class Emitter { + constructor() { + this._listeners = {}; + } + + on(event, callback) { + const eventListeners = this._listeners[event] || []; + eventListeners.push(callback); + this._listeners[event] = eventListeners; + const unsubscribe = () => { + this._listeners[event] = eventListeners.filter(cb => cb !== callback); + }; + return unsubscribe; + } + + trigger(event) { + const callbacks = this._listeners[event]; + if (callbacks) { + callbacks.forEach(cb => cb()); + } + } + } + + window.Emitter = Emitter; + //export default AudioSource; +})(); diff --git a/experiments/audioAbstraction/index.html b/experiments/audioAbstraction/index.html new file mode 100644 index 00000000..ecadab76 --- /dev/null +++ b/experiments/audioAbstraction/index.html @@ -0,0 +1,34 @@ + + + + + + + + Audio Abstraction + + + + + + + +
+ +
+ + +
+ +
+ + + + + + + + \ No newline at end of file