Commit first draft of source abstraction expriment

This commit is contained in:
Jordan Eldredge 2017-09-28 21:40:46 -07:00
parent 87b8eaf0b9
commit 576f1e9942
6 changed files with 517 additions and 47 deletions

View file

@ -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",

View file

@ -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();
});

View file

@ -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;
})();

View file

@ -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;
})();

View file

@ -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;
})();

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Audio Abstraction</title>
</head>
<body>
<select id='sourceType'>
<option value='buffer'>Bufer</option>
<option value='element'>Element</option>
</select>
<button id='play'>Play</button>
<button id='pause'>Pause</button>
<button id='stop'>Stop</button>
<br />
<input type='file' id='eject' name='eject' />
<br />
<input id='url' name='url' value="../../mp3/llama-2.91.mp3" />
<button id='loadUrl'>Load URL</button>
<br />
<input type='range' id='position' min="0" max="100" />
<br />
<textarea id='info' cols="50" rows="20"></textarea>
<script src="./emitter.js"></script>
<script src="./bufferSource.js"></script>
<script src="./elementSource.js"></script>
<script src="./app.js"></script>
</body>
</html>