Skip to content

Instantly share code, notes, and snippets.

@nikorisoft
Created December 13, 2020 19:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nikorisoft/4f12bff7351c351f0e886a60bda10140 to your computer and use it in GitHub Desktop.
Save nikorisoft/4f12bff7351c351f0e886a60bda10140 to your computer and use it in GitHub Desktop.
WebAudio-based player class (incomplete)
import { sprintf } from "sprintf-js";
import { encode } from "querystring";
export enum PlayerState {
STOPPED = 0,
PAUSED = 1,
PLAYING = 2
};
interface NodeInfo<T> {
node: AudioBufferSourceNode;
info: BufferInfo<T>;
}
interface BufferInfo<T> {
buffer: AudioBuffer;
startTime: number;
startOffset: number;
offset: number; // (for UI)
next: number; // Next offset in sample
nodes: NodeInfo<T>[];
metadata: T;
}
interface Progress<T> {
progress: number;
metadata: T;
}
type BufferedPlayerEventHandler = (...args: any[]) => void;
type BufferedPlayerEvent = "songChanged" | "noNextBuffer";
export class BufferedPlayer<Metadata = any> {
static START_MARGIN_SEC = 0.3;
static BUFFER_THRESHOLD_MAGNIFIER = 1.5;
protected ctx: AudioContext;
protected bufferSize: number;
protected buffers: BufferInfo<Metadata>[];
protected state: PlayerState;
protected nextPlayTime: number;
protected handlers: {
[event: string]: BufferedPlayerEventHandler[];
};
constructor(ctx: AudioContext, bufferSize = 10.0) {
this.ctx = ctx;
this.bufferSize = bufferSize;
this.buffers = [];
this.state = PlayerState.STOPPED;
this.nextPlayTime = -1;
this.handlers = {};
}
public decodeData(data: ArrayBuffer): Promise<AudioBuffer> {
return this.ctx.decodeAudioData(data);
}
public getCurrentProgress(): Progress<Metadata> | null {
if (this.buffers.length > 0) {
const buffer = this.buffers[0];
const progress = (buffer.startTime < 0 ? buffer.startOffset + buffer.offset :
this.ctx.currentTime - buffer.startTime + buffer.startOffset + buffer.offset);
return {
progress: progress < 0 ? 0 : progress,
metadata: buffer.metadata
};
} else {
return null;
}
}
protected lock() {
//
}
protected unlock() {
//
}
public queueBuffer(buffer: AudioBuffer, metadata: Metadata, offset: number) {
this.lock();
this.buffers.push({
buffer,
offset,
startTime: -1,
startOffset: 0,
next: 0,
nodes: [],
metadata
});
this.unlock();
}
public play() {
this.lock();
if (this.state === PlayerState.STOPPED || this.state === PlayerState.PAUSED) {
console.debug("Play() called and start playing")
const nextNode = this.findNextNode();
if (nextNode === null) {
// Queue two buffers
this.queueNext();
this.queueNext();
}
} else {
console.debug("Play() called but ignore because already playing");
}
this.state = PlayerState.PLAYING;
this.unlock();
}
public stop() {
this.lock();
for (const buffer of this.buffers) {
const nodes = buffer.nodes;
// Remove all the nodes from buffer first
buffer.nodes = [];
// And stop the nodes
for (const node of nodes) {
node.node.stop();
}
}
this.buffers = [];
this.nextPlayTime = -1;
this.state = PlayerState.STOPPED;
this.unlock();
}
public isPaused(): boolean {
return this.state === PlayerState.PAUSED;
}
public pause() {
this.lock();
if (this.buffers.length > 0) {
const currentBuffer = this.buffers[0];
this.resetNext(this.ctx.currentTime - currentBuffer.startTime + currentBuffer.startOffset);
this.state = PlayerState.PAUSED;
}
this.unlock();
}
protected resetNext(newPos: number) {
if (this.buffers.length > 0) {
const currentBuffer = this.buffers[0];
currentBuffer.startTime = -1;
currentBuffer.startOffset = newPos;
currentBuffer.next = Math.floor(newPos * currentBuffer.buffer.sampleRate);
}
for (const buffer of this.buffers) {
for (const node of buffer.nodes) {
node.node.stop();
}
buffer.nodes = [];
}
this.nextPlayTime = -1;
}
public seek(newPos: number): boolean {
this.lock();
if (this.buffers.length > 0) {
const currentBuffer = this.buffers[0];
newPos -= currentBuffer.offset;
if (newPos < 0 || newPos >= currentBuffer.buffer.duration) {
this.unlock();
return false;
}
this.resetNext(newPos);
this.queueNext();
this.queueNext();
}
this.unlock();
return false;
}
protected findNextNode(): NodeInfo<Metadata> | null {
for (const buffer of this.buffers) {
if (buffer.nodes.length > 0) {
return buffer.nodes[0];
}
}
return null;
}
protected queueNext(): boolean {
let next: BufferInfo<Metadata> | null = null;
for (const buffer of this.buffers) {
if (buffer.next < buffer.buffer.length) {
next = buffer;
break;
}
}
if (next == null) {
return false;
}
if (this.buffers.length === 1) {
this.emitEvent("noNextBuffer");
}
const srcBuffer = next.buffer;
const len = this.bufferSize * srcBuffer.sampleRate;
const end = srcBuffer.length > next.next + len * BufferedPlayer.BUFFER_THRESHOLD_MAGNIFIER ?
next.next + len : srcBuffer.length;
const buffer = this.ctx.createBuffer(srcBuffer.numberOfChannels,
end - next.next, srcBuffer.sampleRate);
for (let ch = 0; ch < srcBuffer.numberOfChannels; ch++) {
const dataSrc = srcBuffer.getChannelData(ch);
const dataDest = buffer.getChannelData(ch);
dataDest.set(dataSrc.slice(next.next, end));
}
next.next = end;
const src = this.ctx.createBufferSource();
src.buffer = buffer;
src.connect(this.ctx.destination);
const nodeInfo = {
node: src,
info: next
};
const self = this;
src.onended = (event) => {
self.finishNode(nodeInfo);
};
const time = (this.nextPlayTime < 0 ?
this.ctx.currentTime + BufferedPlayer.START_MARGIN_SEC :
this.nextPlayTime);
if (this.nextPlayTime < 0) {
this.emitEvent("songChanged", next.metadata);
}
if (next.startTime < 0) {
next.startTime = time;
}
console.debug("%s (- %d) is scheduled at %f", next.metadata, end, time);
this.nextPlayTime = time + buffer.duration;
src.start(time);
next.nodes.push(nodeInfo);
return true;
}
protected emitEvent(event: BufferedPlayerEvent, ...args: any[]) {
if (this.handlers[event] != null) {
for (const handler of this.handlers[event]) {
try {
handler(...args);
} catch (e) {
console.warn("Event handler %s raised error", e);
}
}
}
}
protected finishNode(nodeInfo: NodeInfo<Metadata>) {
this.lock();
const buffer = nodeInfo.info;
const index = buffer.nodes.findIndex((n) => n === nodeInfo);
if (index >= 0) {
buffer.nodes.splice(index, 1);
console.debug("Node (%d) finished and removed.", index);
if (buffer.nodes.length === 0) {
const bufferIndex = this.buffers.findIndex((b) => b === buffer);
if (bufferIndex >= 0) {
this.buffers.splice(bufferIndex, 1);
if (this.buffers.length > 0) {
this.emitEvent("songChanged", this.buffers[0].metadata);
} else {
this.emitEvent("songChanged", null);
}
}
}
this.queueNext();
}
this.unlock();
}
public on(event: BufferedPlayerEvent, func: BufferedPlayerEventHandler): void {
if (this.handlers[event] == null) {
this.handlers[event] = [func];
} else {
this.handlers[event].push(func);
}
}
public off(event: BufferedPlayerEvent, func: BufferedPlayerEventHandler): void {
if (this.handlers[event] != null) {
this.handlers[event] = this.handlers[event].filter((f) => f !== func);
}
}
public toString() {
return "State: " + this.state + "\nBuffers: \n" +
(this.buffers.reduce((p, buf, index) => p + sprintf(" [%d] Dur = %.03f Next = %d Start = %.03f (%s)\n%s\n", index, buf.buffer.duration, buf.next, buf.startTime, JSON.stringify(buf.metadata),
buf.nodes.reduce((p, node, index) => p + sprintf(" [%d] Dur = %.03f\n", index, node.node.buffer?.duration), "")
), ""));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment