web-amr
Version:
AMR Player for the web
200 lines (199 loc) • 6.19 kB
JavaScript
import { AMR } from "./vendor/amrnb";
const getAudioContext = () => {
if (window.AudioContext)
return new window.AudioContext();
throw new Error("Your browser cannot play this audio.");
};
const LOADEDDATA = new Event("loadeddata");
const TIMEUPDATE = new Event("timeupdate");
const PLAYERENDED = new Event("playerended");
const MEDIA_ERR_ABORTED = 1;
const MEDIA_ERR_NETWORK = 2;
const MEDIA_ERR_DECODE = 3;
const MEDIA_ERR_SRC_NOT_SUPPORTED = 4;
const decodeError = () => Object.assign(new EventTarget(), {
duration: NaN,
currentTime: NaN,
error: {
message: "Could not decode AMR audio",
code: MEDIA_ERR_DECODE,
MEDIA_ERR_ABORTED,
MEDIA_ERR_NETWORK,
MEDIA_ERR_DECODE,
MEDIA_ERR_SRC_NOT_SUPPORTED,
},
play() {
throw new Error("Player has errored.");
},
pause() {
throw new Error("Player has errored.");
},
fastSeek() {
throw new Error("Player has errored.");
},
get paused() {
return false;
},
get ended() {
return false;
},
});
export const AMRPlayer = (file, { onEnd = () => { } } = {}) => {
const buf = new Uint8Array(file);
const samples = AMR.decode(buf);
if (!samples)
return decodeError();
const audioContext = getAudioContext();
let notPlayed = true;
let isPlaying = false;
let isPaused = false;
let bufferSource;
// in milliseconds
let startedAt = null;
// in milliseconds
let pauseOffset = 0;
const resetTimers = () => {
startedAt = null;
pauseOffset = 0;
};
const events = new EventTarget();
const addEventListener = events.addEventListener.bind(events);
const removeEventListener = events.removeEventListener.bind(events);
const createBufferSource = (startFrom = 0) => {
// 8000 samples/second = 8 samples/millisecond
let offset = startFrom * 8;
const samplesLen = samples.length;
// avoid error when seeked to end and no samples left to play
if (offset >= samplesLen)
offset = samplesLen - 8000;
bufferSource = Object.assign(audioContext.createBufferSource(), {
theEnd: Promise.resolve(),
});
const buffer = audioContext.createBuffer(1, samplesLen - offset, 8000);
buffer.copyToChannel(samples.slice(offset), 0, 0);
const stop = bufferSource.stop.bind(bufferSource);
bufferSource.stop = () => {
try {
stop();
}
catch (e) {
// if source is already ended, make stop no-op
events.dispatchEvent(PLAYERENDED);
}
};
bufferSource.buffer = buffer;
bufferSource.connect(audioContext.destination);
bufferSource.theEnd = new Promise(resolve => {
bufferSource.onended = () => {
isPlaying = false;
if (!isPaused) {
resetTimers();
createBufferSource();
resolve();
events.dispatchEvent(PLAYERENDED);
return onEnd();
}
else
return resolve();
};
});
};
createBufferSource();
// get only on initialisation
const duration = bufferSource.buffer.duration;
// all methods made async to match native audioElement methods
const play = async () => {
if (isPlaying || !bufferSource)
return;
if (pauseOffset >= duration * 1000)
return stop();
startedAt = performance.now() - pauseOffset;
bufferSource.start();
isPlaying = true;
isPaused = false;
notPlayed = false;
};
const pause = async () => {
if (isPlaying && startedAt) {
const current = performance.now();
pauseOffset = current - startedAt;
bufferSource.stop();
const theEnd = bufferSource.theEnd;
createBufferSource(pauseOffset);
isPaused = true;
await theEnd;
}
events.dispatchEvent(TIMEUPDATE);
};
const seek = async (ms) => {
isPaused = true;
if (isPlaying) {
bufferSource.stop();
const theEnd = bufferSource.theEnd;
createBufferSource(ms);
pauseOffset = ms;
await theEnd;
play();
}
else {
createBufferSource(ms);
pauseOffset = ms;
}
events.dispatchEvent(TIMEUPDATE);
};
const stop = async () => {
if (!bufferSource)
return;
resetTimers();
bufferSource.stop();
const theEnd = bufferSource.theEnd;
createBufferSource();
isPaused = false;
await theEnd;
};
// const toggle = () => (isPlaying ? pause() : play());
setInterval(() => {
if (isPlaying) {
events.dispatchEvent(TIMEUPDATE);
}
}, 150);
setTimeout(() => events.dispatchEvent(LOADEDDATA), 100);
return {
addEventListener,
removeEventListener,
play,
pause,
fastSeek(seconds) {
return seek(seconds * 1000);
},
/* these methods don't exist on HTMLMediaElement,
* and are omitted for conformance; however
* if need arises, we could enable them
*/
// stop,
// toggle,
duration,
get paused() {
return !isPlaying;
},
get ended() {
/* Player has been played at least once,
* is not currently playing,
* and timer has been reset to 0
*/
return !notPlayed && !isPlaying && pauseOffset === 0;
},
get currentTime() {
if (isPlaying)
return (performance.now() - startedAt) / 1000;
if (isPaused)
return pauseOffset / 1000;
// must have been stopped
return 0;
},
set currentTime(seconds) {
seek(seconds * 1000);
},
error: null,
};
};