icecast-metadata-player
Version:
Simple to use Javascript class that plays an Icecast stream with real-time metadata updates
396 lines (328 loc) • 11.8 kB
JavaScript
import {
audioContext,
concatBuffers,
event,
state,
fireEvent,
SYNCED,
PCM_SYNCED,
SYNCING,
NOT_SYNCED,
noOp,
} from "./global.js";
// test if worker can spawn a worker for (i.e. everything but iOS)
let canSpawnWorker;
const spawnWorkerTest = new Worker(
URL.createObjectURL(
new Blob(["self.onmessage = () => self.postMessage(!!self.Worker)"], {
type: "text/javascript",
}),
),
);
spawnWorkerTest.onmessage = (r) => {
canSpawnWorker = r.data;
spawnWorkerTest.terminate();
};
spawnWorkerTest.postMessage(null);
export default class FrameQueue {
constructor(icecast, player) {
this.CRC_DURATION = 300000; // milliseconds to cache for crc syncing
this.PCM_DURATION = 60000; // milliseconds to cache for pcm syncing
this._icecast = icecast;
this._player = player;
this.initSync();
this.initQueue();
}
initSync() {
clearTimeout(this._syncTimeout);
this._syncTimeout = null;
this._syncTimeoutReason = null;
this._crcSyncPending = true;
this._syncQueue = [];
this._syncQueueDuration = 0;
this._synAudioResult = null;
this._a = null;
this._b = null;
}
initQueue() {
this._queueIndex = 0;
this._queueSamples = 0;
this._queueSampleRate = 0;
this._crcQueue = [];
this._crcQueueDuration = 0;
this._crcQueueIndexes = {};
this._pcmQueue = [];
this._pcmQueueDuration = 0;
}
get buffered() {
return (
this._queueSamples / this._queueSampleRate - this._player.currentTime || 0
);
}
add(frame) {
// crc queue
const { crc32, duration, samples } = frame;
this._queueSamples += samples;
this._queueSampleRate = frame.header.sampleRate;
this._crcQueue.push({ crc32, duration });
this._crcQueueDuration += duration;
// update queue index
let indexes = this._crcQueueIndexes[crc32];
if (!indexes) {
indexes = [];
this._crcQueueIndexes[crc32] = indexes;
}
indexes.push(this._queueIndex++);
if (this._crcQueueDuration >= this.CRC_DURATION) {
const { crc32, duration } = this._crcQueue.shift();
this._crcQueueDuration -= duration;
// remove the oldest index
const indexes = this._crcQueueIndexes[crc32];
indexes.shift();
// remove the key if there are no indexes left
if (!indexes.length) delete this._crcQueueIndexes[crc32];
}
// pcm queue
this._pcmQueue.push(frame);
this._pcmQueueDuration += duration;
if (this._pcmQueueDuration >= this.PCM_DURATION) {
this._pcmQueueDuration -= this._pcmQueue.shift().duration;
}
}
addAll(frames) {
frames.forEach((frame) => this.add(frame));
}
_addAllSyncQueue(frames) {
for (const frame of frames) {
this._syncQueueDuration += frame.duration;
this._syncQueue.push(frame);
}
}
/**
*
* @param {Array<CodecFrame|OggPage>} frames
*/
async sync(frames) {
// stop syncing if the buffer runs out
if (this._syncTimeout === null) {
const currentBuffered = this.buffered;
this._syncReject = noOp;
this._syncTimeout = setTimeout(() => {
this._syncTimeoutReason = `Buffer underrun after syncing for ${currentBuffered.toFixed(
2,
)} seconds.`;
this._syncReject(this._syncTimeoutReason);
}, currentBuffered * 1000);
}
this._addAllSyncQueue(frames);
return new Promise(async (resolve, reject) => {
if (this._syncTimeoutReason !== null) reject(this._syncTimeoutReason);
else this._syncReject = reject;
let syncState;
// try syncing using crc32 hashes (if the stream data matches exactly)
if (this._crcSyncPending) syncState = this._crcSync();
// try syncing using decoded audio and corelation (if audio data matches)
if (!syncState) {
this._crcSyncPending = false;
syncState = await this._pcmSync();
}
// streams do not match (not synced)
if (!syncState) reject("Old and new request do not match.");
else resolve(syncState);
})
.catch((e) => {
if (
this._icecast.state !== state.STOPPING &&
this._icecast.state !== state.STOPPED
)
this._icecast[fireEvent](
event.WARN,
`Reconnected successfully after ${this._icecast.state}.`,
"Unable to sync old and new request.",
e,
);
const syncQueue = this._syncQueue;
this.initSync();
this.initQueue();
return [syncQueue, NOT_SYNCED];
})
.then((syncState) => {
if ([SYNCED, PCM_SYNCED].includes(syncState[1])) {
this.initSync();
}
return syncState;
});
}
/*
Aligns the queue with a new incoming data by aligning the crc32 hashes
and then returning only the frames that do not existing on the queue.
old data | common data | new data
(old connection) ------------------------|
(new connection) |------------------>
^^^^^^^^^^^^^^ ^^^^
(sync) (frames to return)
*/
_crcSync() {
// get all indexed matches for crc and check
if (!this._syncQueue.length) return [[], SYNCING];
const syncQueueStartIndex = 0;
const syncQueueCrc = this._syncQueue[syncQueueStartIndex].crc32;
const crcSyncPoints = this._crcQueueIndexes[syncQueueCrc];
let matched, outOfFrames, syncPoint;
if (crcSyncPoints) {
align_queues: for (const absoluteSyncPoint of crcSyncPoints) {
syncPoint =
absoluteSyncPoint - (this._queueIndex - this._crcQueue.length);
for (
let i = syncQueueStartIndex;
i < this._syncQueue.length && syncPoint + i < this._crcQueue.length;
i++
)
if (this._crcQueue[syncPoint + i].crc32 !== this._syncQueue[i].crc32)
continue align_queues; // failed to match
outOfFrames =
syncPoint + this._syncQueue.length <= this._crcQueue.length;
matched = true;
break; // full match
}
// have some overlapping frames, but none are new frames
if (outOfFrames) return [[], SYNCING];
if (matched) {
const sliceIndex = this._crcQueue.length - syncPoint;
// prettier-ignore
this._icecast[fireEvent](
event.WARN,
`Reconnected successfully after ${this._icecast.state}.`,
`Found ${sliceIndex} frames (${(this._crcQueue
.slice(syncPoint)
.reduce((acc, { duration }) => acc + duration, 0) / 1000).toFixed(3)} seconds) of overlapping audio data in new request.`,
"Synchronized old and new request."
);
return [this._syncQueue.slice(sliceIndex), SYNCED];
}
}
}
/*
Syncs the old and new data using correlation between decoded audio.
A new player will be constructed after this sync is completed.
old data | common data | new data
(time scale) -2 -1 0 +1 +2
(old connection) -----------------------------|
^^^^^|^^^^^^^^^| |
| sampleOffsetFromEnd buffered (metadataTimestamp)
(time scale) -2 -1 0 +1 +2
(new connection) |-----------|--->
| ^^^^^^^^^^^^|^^^^
delay syncLength
*/
async _pcmSync() {
try {
const correlationSyncLength = 1; // seconds
const initialGranularity = 16;
const samplesToDuration = (samples, rate) => samples / rate;
if (!this._synAudioResult) {
let SynAudio;
try {
SynAudio = (
await import(
/* webpackChunkName: "synaudio", webpackPrefetch: true */
"synaudio"
)
).default;
} catch (e) {
this._icecast[fireEvent](
event.WARN,
"Failed to synchronize old and new stream",
"Missing `synaudio` dependency.",
);
return;
}
const [pcmQueueDecoded, syncQueueDecoded, sampleRate] =
await this._decodeQueues();
const correlationSampleSize = sampleRate * correlationSyncLength;
// more data is needed to meet the correlationSampleSize
if (syncQueueDecoded.samplesDecoded <= correlationSampleSize)
return [[], SYNCING];
const synAudio = new SynAudio({
correlationSampleSize,
initialGranularity,
});
this._synAudioResult = await (canSpawnWorker
? synAudio.syncWorkerConcurrent(
pcmQueueDecoded,
syncQueueDecoded,
Math.max(navigator.hardwareConcurrency - 1, 1),
)
: synAudio.syncWorker(pcmQueueDecoded, syncQueueDecoded));
this._synAudioResult.offsetFromEnd = samplesToDuration(
pcmQueueDecoded.samplesDecoded - this._synAudioResult.sampleOffset,
sampleRate,
); // total queue samples decoded - sample offset (sampleOffset from end of buffer)
}
// anything lower than .5 is likely not synced, but it might sound better than some random sync point
const { correlation, offsetFromEnd } = this._synAudioResult;
let delay = (this.buffered - offsetFromEnd) * 1000; // if negative, sync is before playback position, positive, sync after playback position
// more frames need to be cut than exist on the sync queue
if (-delay > this._syncQueueDuration) return [[], SYNCING];
const frameOverlap = 0;
if (delay < 0) {
// slice the sync frame with 'n' frame overlap and start immediately
let sliceIndex = 0;
for (
let t = 0;
sliceIndex < this._syncQueue.length - frameOverlap && t > delay;
sliceIndex++
)
t -= this._syncQueue[sliceIndex].duration;
this._syncQueue = this._syncQueue.slice(sliceIndex - frameOverlap);
} else {
// delay start with 'n' frame overlap
for (let i = 0; i < frameOverlap && i < this._syncQueue.length; i++)
delay -= this._syncQueue[i].duration;
}
// prettier-ignore
this._icecast[fireEvent](
event.WARN,
`Reconnected successfully after ${this._icecast.state}.`,
`Synchronized old and new request with ${(Math.round(correlation * 10000) / 100).toFixed(2)}% confidence.`
);
this.initQueue();
return [this._syncQueue, PCM_SYNCED, delay];
} catch {}
}
async _decodeQueues() {
const decode = (queue, timeFromEnd) => {
let sliceIndex = queue.length - 1;
for (
let duration = 0;
duration < timeFromEnd && sliceIndex > 0;
sliceIndex--
)
duration += queue[sliceIndex].duration;
return this._icecast[audioContext].decodeAudioData(
concatBuffers(queue.slice(sliceIndex).map(({ data }) => data)).buffer,
);
};
[this._a, this._b] = await Promise.all([
// decode the pcm queue only once, decode only up to twice the amount of buffered audio
this._a ? this._a : decode(this._pcmQueue, this.buffered * 2000),
decode(this._syncQueue, Infinity),
]);
const getDecodedAudio = (decodedAudioData) => {
const decoded = {
channelData: [],
samplesDecoded: decodedAudioData.length,
};
for (let i = 0; i < decodedAudioData.numberOfChannels; i++)
decoded.channelData.push(
Float32Array.from(decodedAudioData.getChannelData(i)),
);
return decoded;
};
return [
getDecodedAudio(this._a),
getDecodedAudio(this._b),
this._a.sampleRate,
];
}
}