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