-
-
Save nikorisoft/4f12bff7351c351f0e886a60bda10140 to your computer and use it in GitHub Desktop.
WebAudio-based player class (incomplete)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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