Standardize on elementSource

This commit is contained in:
Jordan Eldredge 2018-01-21 21:44:34 -08:00
parent a08ed96ab5
commit 00274e9483
6 changed files with 20 additions and 397 deletions

View file

@ -17,6 +17,5 @@ if (hash) {
export const skinUrl = config.skinUrl === undefined ? skin : config.skinUrl;
export const audioUrl = config.audioUrl === undefined ? audio : config.audioUrl;
export const hideAbout = config.hideAbout || false;
export const elementSource = config.elementSource || true;
export const initialState = config.initialState || undefined;
export const sentryDsn = SENTRY_DSN;

View file

@ -1,112 +0,0 @@
# Audio Source Abstraction
An attempt to build a common interface for a media file source node. The goal is to have two implementations one that uses `MediaElementAudioSourceNode` and one that uses `AudioBufferSourceNode`.
See `./elementSource.js` and `./bufferSource.js`.
## Interface methods
### `constructor(audioContext, destinationNode)`
Construct a new instance:
const source = new Source(audoContext, context.destination);
### `disconnect()`
Disconnect the source from the destination and clean up. The source cannot be reused.
### `loadFile(file)`
Load a file supplied by the user from a `<inpyt type='file'>`.
Returns a Promise so that users can play when the file is done loading.
TODO: Throw if cannot play
### `loadUrl(url: string)`
Load a url. Note that this URL is subject to CORS.
Returns a Promise so that users can play when the file is done loading.
TODO: Throw if cannot play
### `play()`
Start playing the source.
* If already playing, start again at the begining.
* If paused, resume.
* If user has called `seekToTime()`, starts at that time.
* Has no affect if waiting.
Returns a Promise
### `pause()`
* If already puased, does nothing.
* If stopped, does nothing.
* If waiting, puts us in paused mode.
* If playing, pauses the playback at the current time.
### `stop()`
Stops playback, reverts the current time to 0.
### `seekToTime(seconds)`
Updates the current time to `seconds`.
Does not change the current status. If already playing, media will continue to play from the new time.
### `getStatus(): STATUS`
Get the current status. One of:
* "PLAYING"
* "PAUSED"
* "STOPPED"
### `getDuration(): seconds | null`
Get the duration of the current track in seconds, or `null` if no track is loaded.
### `getStalled(): boolean`
Are we buffering/loading?
### `getTimeElapsed(): seconds | null`
How many secons have elapsed?
### `getNumberOfChannels(): number | null`
### `getSampleRate(): number | null`
### `getLoop(): boolean`
### `setLoop(shouldLoop: boolean)`
### `setAutoPlay(shouldAutoPlay: boolean)`
### `on(EVENT: string, callback: () => {})`
Subscribe to events
Options:
* "statusChange": Our play status has changed.
* "positionChange": Our position changed.
* "stallChanged": We either started or stopped stalling.
## Manual test plan:
* Play a track on loop. Ensure the position indicator also loops.
* Pause a track after looping. Resume that track.
* Pause a track. Load a large file. Assert that the loading indicator shows.
* Pause a track. Resume it with play.
* Pause a track. Resume it with pause.
* Play a track. Part way through press play again. Asserrt that the track starts over.
* While a track is loading: Press play. Nothing should happen.
* Load a large file while another file is playing. Assert that the first file stops playing while we wait.

View file

@ -1,253 +0,0 @@
import Emitter from "./emitter";
const invariant = (assertion, message) => {
if (!assertion) {
console.error(message);
}
};
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 = {
PLAYING: "PLAYING",
STOPPED: "STOPPED",
PAUSED: "PAUSED"
};
export default class BufferSource extends 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._stalled = false;
this._startTime = null;
this._pausedAtTime = null;
this._onEnded = this._onEnded.bind(this);
}
disconnect() {
if (this._source != null) {
this._source.disconnect();
this._source = null;
}
}
// Just adapt the callback into a promise.
_decodeAudioData(buffer) {
return new Promise((resolve, reject) => {
this._context.decodeAudioData(buffer, resolve, reject);
});
}
_startLoading() {
this.stop();
// Ensure we don't start playing while we are waiting to load.
this._buffer = null;
this._setStalled(true);
}
async loadUrl(url) {
this._startLoading();
const arrayBuffer = await readUrlAsArrayBuffer(url);
return this._loadArrayBuffer(arrayBuffer);
}
async _loadArrayBuffer(arrayBuffer) {
this._buffer = await this._decodeAudioData(arrayBuffer);
this.trigger("loaded");
this._setStalled(false);
this._setStatus(STATUS.STOPPED);
}
play() {
if (this._stalled) {
return;
}
switch (this._status) {
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) {
if (!this.getDuration()) {
return;
}
time = Math.min(time, this.getDuration());
time = Math.max(time, 0);
this._start(time);
}
getStatus() {
return this._status;
}
getStalled() {
return this._stalled;
}
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"
);
const totalTime = this._context.currentTime - this._startTime;
// If looping, the time may be greater than the song's duration.
return totalTime % this.getDuration();
case STATUS.STOPPED:
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;
}
// TODO: This is actually the channels of the audio node, not the source media.
return this._buffer.numberOfChannels;
}
getSampleRate() {
if (this._buffer == null) {
return null;
}
// TODO: This is actually the sample rate of the audio node, not the source media.
return this._buffer.sampleRate;
}
/*
getLoop() {
return this._loop;
}
setLoop(shouldLoop) {
this._loop = shouldLoop;
if (this._source) {
this._source.loop = this._loop;
}
}
*/
_start(position) {
invariant(!isNaN(position));
if (this._buffer == null) {
return;
}
if (this._source) {
// Does this work?
this.disconnect();
}
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();
}, 50);
}
_stopMonitoringPosition() {
clearTimeout(this._nextPositionUpdate);
}
_setStatus(status) {
this._status = status;
if (status === STATUS.PLAYING) {
this._startMonitoringPosition();
} else {
this._stopMonitoringPosition();
}
this.trigger("statusChange");
}
_setStalled(stalled) {
this._stalled = stalled;
this.trigger("statusChange");
}
_onEnded() {
if (this._status === STATUS.PLAYING) {
this.trigger("ended");
this._setStatus(STATUS.STOPPED);
}
}
}

View file

@ -1,14 +1,29 @@
import Emitter from "./emitter";
const STATUS = {
PLAYING: "PLAYING",
STOPPED: "STOPPED",
PAUSED: "PAUSED"
};
export default class ElementSource extends Emitter {
export default class ElementSource {
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());
}
}
constructor(context, destination) {
super();
this._listeners = {};
this._context = context;
this._destination = destination;
this._audio = document.createElement("audio");

View file

@ -1,22 +0,0 @@
export default 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());
}
}
}

View file

@ -1,7 +1,5 @@
/* Emulate the native <audio> element with Web Audio API */
import { elementSource } from "../config";
import { BANDS } from "../constants";
import BufferSource from "./bufferSource";
import ElementSource from "./elementSource";
export default class Media {
@ -76,9 +74,7 @@ export default class Media {
// |
// <destination>
this._source = elementSource
? new ElementSource(this._context, this._staticSource)
: new BufferSource(this._context, this._staticSource);
this._source = new ElementSource(this._context, this._staticSource);
this._source.on("positionChange", () => {
this._callbacks.timeupdate();