mirror of
https://github.com/libretro/Lakka-LibreELEC.git
synced 2024-11-25 23:36:18 +00:00
b4accbb4d6
As per forum request: https://forum.libreelec.tv/thread/23750-snapserver-enable-http-json-rpc-http-post-and-websockets/?postID=153424#post153424
892 lines
36 KiB
JavaScript
892 lines
36 KiB
JavaScript
"use strict";
|
|
function setCookie(key, value, exdays = -1) {
|
|
let d = new Date();
|
|
if (exdays < 0)
|
|
exdays = 10 * 365;
|
|
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
|
|
let expires = "expires=" + d.toUTCString();
|
|
document.cookie = key + "=" + value + ";" + expires + ";sameSite=Strict;path=/";
|
|
}
|
|
function getPersistentValue(key, defaultValue = "") {
|
|
if (!!window.localStorage) {
|
|
const value = window.localStorage.getItem(key);
|
|
if (value !== null) {
|
|
return value;
|
|
}
|
|
window.localStorage.setItem(key, defaultValue);
|
|
return defaultValue;
|
|
}
|
|
// Fallback to cookies if localStorage is not available.
|
|
let name = key + "=";
|
|
let decodedCookie = decodeURIComponent(document.cookie);
|
|
let ca = decodedCookie.split(';');
|
|
for (let c of ca) {
|
|
c = c.trimLeft();
|
|
if (c.indexOf(name) == 0) {
|
|
return c.substring(name.length, c.length);
|
|
}
|
|
}
|
|
setCookie(key, defaultValue);
|
|
return defaultValue;
|
|
}
|
|
function getChromeVersion() {
|
|
const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
|
|
return raw ? parseInt(raw[2]) : null;
|
|
}
|
|
function uuidv4() {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
class Tv {
|
|
constructor(sec, usec) {
|
|
this.sec = 0;
|
|
this.usec = 0;
|
|
this.sec = sec;
|
|
this.usec = usec;
|
|
}
|
|
setMilliseconds(ms) {
|
|
this.sec = Math.floor(ms / 1000);
|
|
this.usec = Math.floor(ms * 1000) % 1000000;
|
|
}
|
|
getMilliseconds() {
|
|
return this.sec * 1000 + this.usec / 1000;
|
|
}
|
|
}
|
|
class BaseMessage {
|
|
constructor(_buffer) {
|
|
this.type = 0;
|
|
this.id = 0;
|
|
this.refersTo = 0;
|
|
this.received = new Tv(0, 0);
|
|
this.sent = new Tv(0, 0);
|
|
this.size = 0;
|
|
}
|
|
deserialize(buffer) {
|
|
let view = new DataView(buffer);
|
|
this.type = view.getUint16(0, true);
|
|
this.id = view.getUint16(2, true);
|
|
this.refersTo = view.getUint16(4, true);
|
|
this.received = new Tv(view.getInt32(6, true), view.getInt32(10, true));
|
|
this.sent = new Tv(view.getInt32(14, true), view.getInt32(18, true));
|
|
this.size = view.getUint32(22, true);
|
|
}
|
|
serialize() {
|
|
this.size = 26 + this.getSize();
|
|
let buffer = new ArrayBuffer(this.size);
|
|
let view = new DataView(buffer);
|
|
view.setUint16(0, this.type, true);
|
|
view.setUint16(2, this.id, true);
|
|
view.setUint16(4, this.refersTo, true);
|
|
view.setInt32(6, this.sent.sec, true);
|
|
view.setInt32(10, this.sent.usec, true);
|
|
view.setInt32(14, this.received.sec, true);
|
|
view.setInt32(18, this.received.usec, true);
|
|
view.setUint32(22, this.size, true);
|
|
return buffer;
|
|
}
|
|
getSize() {
|
|
return 0;
|
|
}
|
|
}
|
|
class CodecMessage extends BaseMessage {
|
|
constructor(buffer) {
|
|
super(buffer);
|
|
this.codec = "";
|
|
this.payload = new ArrayBuffer(0);
|
|
if (buffer) {
|
|
this.deserialize(buffer);
|
|
}
|
|
this.type = 1;
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
let view = new DataView(buffer);
|
|
let codecSize = view.getInt32(26, true);
|
|
let decoder = new TextDecoder("utf-8");
|
|
this.codec = decoder.decode(buffer.slice(30, 30 + codecSize));
|
|
let payloadSize = view.getInt32(30 + codecSize, true);
|
|
console.log("payload size: " + payloadSize);
|
|
this.payload = buffer.slice(34 + codecSize, 34 + codecSize + payloadSize);
|
|
console.log("payload: " + this.payload);
|
|
}
|
|
}
|
|
class TimeMessage extends BaseMessage {
|
|
constructor(buffer) {
|
|
super(buffer);
|
|
this.latency = new Tv(0, 0);
|
|
if (buffer) {
|
|
this.deserialize(buffer);
|
|
}
|
|
this.type = 4;
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
let view = new DataView(buffer);
|
|
this.latency = new Tv(view.getInt32(26, true), view.getInt32(30, true));
|
|
}
|
|
serialize() {
|
|
let buffer = super.serialize();
|
|
let view = new DataView(buffer);
|
|
view.setInt32(26, this.latency.sec, true);
|
|
view.setInt32(30, this.latency.usec, true);
|
|
return buffer;
|
|
}
|
|
getSize() {
|
|
return 8;
|
|
}
|
|
}
|
|
class JsonMessage extends BaseMessage {
|
|
constructor(buffer) {
|
|
super(buffer);
|
|
if (buffer) {
|
|
this.deserialize(buffer);
|
|
}
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
let view = new DataView(buffer);
|
|
let size = view.getUint32(26, true);
|
|
let decoder = new TextDecoder();
|
|
this.json = JSON.parse(decoder.decode(buffer.slice(30, 30 + size)));
|
|
}
|
|
serialize() {
|
|
let buffer = super.serialize();
|
|
let view = new DataView(buffer);
|
|
let jsonStr = JSON.stringify(this.json);
|
|
view.setUint32(26, jsonStr.length, true);
|
|
let encoder = new TextEncoder();
|
|
let encoded = encoder.encode(jsonStr);
|
|
for (let i = 0; i < encoded.length; ++i)
|
|
view.setUint8(30 + i, encoded[i]);
|
|
return buffer;
|
|
}
|
|
getSize() {
|
|
let encoder = new TextEncoder();
|
|
let encoded = encoder.encode(JSON.stringify(this.json));
|
|
return encoded.length + 4;
|
|
// return JSON.stringify(this.json).length;
|
|
}
|
|
}
|
|
class HelloMessage extends JsonMessage {
|
|
constructor(buffer) {
|
|
super(buffer);
|
|
this.mac = "";
|
|
this.hostname = "";
|
|
this.version = "0.1.0";
|
|
this.clientName = "Snapweb";
|
|
this.os = "";
|
|
this.arch = "web";
|
|
this.instance = 1;
|
|
this.uniqueId = "";
|
|
this.snapStreamProtocolVersion = 2;
|
|
if (buffer) {
|
|
this.deserialize(buffer);
|
|
}
|
|
this.type = 5;
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
this.mac = this.json["MAC"];
|
|
this.hostname = this.json["HostName"];
|
|
this.version = this.json["Version"];
|
|
this.clientName = this.json["ClientName"];
|
|
this.os = this.json["OS"];
|
|
this.arch = this.json["Arch"];
|
|
this.instance = this.json["Instance"];
|
|
this.uniqueId = this.json["ID"];
|
|
this.snapStreamProtocolVersion = this.json["SnapStreamProtocolVersion"];
|
|
}
|
|
serialize() {
|
|
this.json = { "MAC": this.mac, "HostName": this.hostname, "Version": this.version, "ClientName": this.clientName, "OS": this.os, "Arch": this.arch, "Instance": this.instance, "ID": this.uniqueId, "SnapStreamProtocolVersion": this.snapStreamProtocolVersion };
|
|
return super.serialize();
|
|
}
|
|
}
|
|
class ServerSettingsMessage extends JsonMessage {
|
|
constructor(buffer) {
|
|
super(buffer);
|
|
this.bufferMs = 0;
|
|
this.latency = 0;
|
|
this.volumePercent = 0;
|
|
this.muted = false;
|
|
if (buffer) {
|
|
this.deserialize(buffer);
|
|
}
|
|
this.type = 3;
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
this.bufferMs = this.json["bufferMs"];
|
|
this.latency = this.json["latency"];
|
|
this.volumePercent = this.json["volume"];
|
|
this.muted = this.json["muted"];
|
|
}
|
|
serialize() {
|
|
this.json = { "bufferMs": this.bufferMs, "latency": this.latency, "volume": this.volumePercent, "muted": this.muted };
|
|
return super.serialize();
|
|
}
|
|
}
|
|
class PcmChunkMessage extends BaseMessage {
|
|
constructor(buffer, sampleFormat) {
|
|
super(buffer);
|
|
this.timestamp = new Tv(0, 0);
|
|
// payloadSize: number = 0;
|
|
this.payload = new ArrayBuffer(0);
|
|
this.idx = 0;
|
|
this.deserialize(buffer);
|
|
this.sampleFormat = sampleFormat;
|
|
this.type = 2;
|
|
}
|
|
deserialize(buffer) {
|
|
super.deserialize(buffer);
|
|
let view = new DataView(buffer);
|
|
this.timestamp = new Tv(view.getInt32(26, true), view.getInt32(30, true));
|
|
// this.payloadSize = view.getUint32(34, true);
|
|
this.payload = buffer.slice(38); //, this.payloadSize + 38));// , this.payloadSize);
|
|
// console.log("ts: " + this.timestamp.sec + " " + this.timestamp.usec + ", payload: " + this.payloadSize + ", len: " + this.payload.byteLength);
|
|
}
|
|
readFrames(frames) {
|
|
let frameCnt = frames;
|
|
let frameSize = this.sampleFormat.frameSize();
|
|
if (this.idx + frames > this.payloadSize() / frameSize)
|
|
frameCnt = (this.payloadSize() / frameSize) - this.idx;
|
|
let begin = this.idx * frameSize;
|
|
this.idx += frameCnt;
|
|
let end = begin + frameCnt * frameSize;
|
|
// console.log("readFrames: " + frames + ", result: " + frameCnt + ", begin: " + begin + ", end: " + end + ", payload: " + this.payload.byteLength);
|
|
return this.payload.slice(begin, end);
|
|
}
|
|
getFrameCount() {
|
|
return (this.payloadSize() / this.sampleFormat.frameSize());
|
|
}
|
|
isEndOfChunk() {
|
|
return this.idx >= this.getFrameCount();
|
|
}
|
|
startMs() {
|
|
return this.timestamp.getMilliseconds() + 1000 * (this.idx / this.sampleFormat.rate);
|
|
}
|
|
duration() {
|
|
return 1000 * ((this.getFrameCount() - this.idx) / this.sampleFormat.rate);
|
|
}
|
|
payloadSize() {
|
|
return this.payload.byteLength;
|
|
}
|
|
clearPayload() {
|
|
this.payload = new ArrayBuffer(0);
|
|
}
|
|
addPayload(buffer) {
|
|
let payload = new ArrayBuffer(this.payload.byteLength + buffer.byteLength);
|
|
let view = new DataView(payload);
|
|
let viewOld = new DataView(this.payload);
|
|
let viewNew = new DataView(buffer);
|
|
for (let i = 0; i < viewOld.byteLength; ++i) {
|
|
view.setInt8(i, viewOld.getInt8(i));
|
|
}
|
|
for (let i = 0; i < viewNew.byteLength; ++i) {
|
|
view.setInt8(i + viewOld.byteLength, viewNew.getInt8(i));
|
|
}
|
|
this.payload = payload;
|
|
}
|
|
}
|
|
class AudioStream {
|
|
constructor(timeProvider, sampleFormat, bufferMs) {
|
|
this.timeProvider = timeProvider;
|
|
this.sampleFormat = sampleFormat;
|
|
this.bufferMs = bufferMs;
|
|
this.chunks = new Array();
|
|
// setRealSampleRate(sampleRate: number) {
|
|
// if (sampleRate == this.sampleFormat.rate) {
|
|
// this.correctAfterXFrames = 0;
|
|
// }
|
|
// else {
|
|
// this.correctAfterXFrames = Math.ceil((this.sampleFormat.rate / sampleRate) / (this.sampleFormat.rate / sampleRate - 1.));
|
|
// console.debug("setRealSampleRate: " + sampleRate + ", correct after X: " + this.correctAfterXFrames);
|
|
// }
|
|
// }
|
|
this.chunk = undefined;
|
|
this.volume = 1;
|
|
this.muted = false;
|
|
this.lastLog = 0;
|
|
}
|
|
setVolume(percent, muted) {
|
|
// let base = 10;
|
|
this.volume = percent / 100; // (Math.pow(base, percent / 100) - 1) / (base - 1);
|
|
console.log("setVolume: " + percent + " => " + this.volume + ", muted: " + this.muted);
|
|
this.muted = muted;
|
|
}
|
|
addChunk(chunk) {
|
|
this.chunks.push(chunk);
|
|
// let oldest = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds();
|
|
// let newest = this.timeProvider.serverNow() - this.chunks[this.chunks.length - 1].timestamp.getMilliseconds();
|
|
// console.debug("chunks: " + this.chunks.length + ", oldest: " + oldest.toFixed(2) + ", newest: " + newest.toFixed(2));
|
|
while (this.chunks.length > 0) {
|
|
let age = this.timeProvider.serverNow() - this.chunks[0].timestamp.getMilliseconds();
|
|
// todo: consider buffer ms
|
|
if (age > 5000 + this.bufferMs) {
|
|
this.chunks.shift();
|
|
console.log("Dropping old chunk: " + age.toFixed(2) + ", left: " + this.chunks.length);
|
|
}
|
|
else
|
|
break;
|
|
}
|
|
}
|
|
getNextBuffer(buffer, playTimeMs) {
|
|
if (!this.chunk) {
|
|
this.chunk = this.chunks.shift();
|
|
}
|
|
// let age = this.timeProvider.serverTime(this.playTime * 1000) - startMs;
|
|
let frames = buffer.length;
|
|
// console.debug("getNextBuffer: " + frames + ", play time: " + playTimeMs.toFixed(2));
|
|
let left = new Float32Array(frames);
|
|
let right = new Float32Array(frames);
|
|
let read = 0;
|
|
let pos = 0;
|
|
// let volume = this.muted ? 0 : this.volume;
|
|
let serverPlayTimeMs = this.timeProvider.serverTime(playTimeMs);
|
|
if (this.chunk) {
|
|
let age = serverPlayTimeMs - this.chunk.startMs(); // - 500;
|
|
let reqChunkDuration = frames / this.sampleFormat.msRate();
|
|
let secs = Math.floor(Date.now() / 1000);
|
|
if (this.lastLog != secs) {
|
|
this.lastLog = secs;
|
|
console.log("age: " + age.toFixed(2) + ", req: " + reqChunkDuration);
|
|
}
|
|
if (age < -reqChunkDuration) {
|
|
console.log("age: " + age.toFixed(2) + " < req: " + reqChunkDuration * -1 + ", chunk.startMs: " + this.chunk.startMs().toFixed(2) + ", timestamp: " + this.chunk.timestamp.getMilliseconds().toFixed(2));
|
|
console.log("Chunk too young, returning silence");
|
|
}
|
|
else {
|
|
if (Math.abs(age) > 5) {
|
|
// We are 5ms apart, do a hard sync, i.e. don't play faster/slower,
|
|
// but seek to the desired position instead
|
|
while (this.chunk && age > this.chunk.duration()) {
|
|
console.log("Chunk too old, dropping (age: " + age.toFixed(2) + " > " + this.chunk.duration().toFixed(2) + ")");
|
|
this.chunk = this.chunks.shift();
|
|
if (!this.chunk)
|
|
break;
|
|
age = serverPlayTimeMs - this.chunk.startMs();
|
|
}
|
|
if (this.chunk) {
|
|
if (age > 0) {
|
|
console.log("Fast forwarding " + age.toFixed(2) + "ms");
|
|
this.chunk.readFrames(Math.floor(age * this.chunk.sampleFormat.msRate()));
|
|
}
|
|
else if (age < 0) {
|
|
console.log("Playing silence " + -age.toFixed(2) + "ms");
|
|
let silentFrames = Math.floor(-age * this.chunk.sampleFormat.msRate());
|
|
left.fill(0, 0, silentFrames);
|
|
right.fill(0, 0, silentFrames);
|
|
read = silentFrames;
|
|
pos = silentFrames;
|
|
}
|
|
age = 0;
|
|
}
|
|
}
|
|
// else if (age > 0.1) {
|
|
// let rate = age * 0.0005;
|
|
// rate = 1.0 - Math.min(rate, 0.0005);
|
|
// console.debug("Age > 0, rate: " + rate);
|
|
// // we are late (age > 0), this means we are not playing fast enough
|
|
// // => the real sample rate seems to be lower, we have to drop some frames
|
|
// this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999);
|
|
// }
|
|
// else if (age < -0.1) {
|
|
// let rate = -age * 0.0005;
|
|
// rate = 1.0 + Math.min(rate, 0.0005);
|
|
// console.debug("Age < 0, rate: " + rate);
|
|
// // we are early (age > 0), this means we are playing too fast
|
|
// // => the real sample rate seems to be higher, we have to insert some frames
|
|
// this.setRealSampleRate(this.sampleFormat.rate * rate); // 0.9999);
|
|
// }
|
|
// else {
|
|
// this.setRealSampleRate(this.sampleFormat.rate);
|
|
// }
|
|
let addFrames = 0;
|
|
let everyN = 0;
|
|
if (age > 0.1) {
|
|
addFrames = Math.ceil(age); // / 5);
|
|
}
|
|
else if (age < -0.1) {
|
|
addFrames = Math.floor(age); // / 5);
|
|
}
|
|
// addFrames = -2;
|
|
let readFrames = frames + addFrames - read;
|
|
if (addFrames != 0)
|
|
everyN = Math.ceil((frames + addFrames - read) / (Math.abs(addFrames) + 1));
|
|
// addFrames = 0;
|
|
// console.debug("frames: " + frames + ", readFrames: " + readFrames + ", addFrames: " + addFrames + ", everyN: " + everyN);
|
|
while ((read < readFrames) && this.chunk) {
|
|
let pcmChunk = this.chunk;
|
|
let pcmBuffer = pcmChunk.readFrames(readFrames - read);
|
|
let payload = new Int16Array(pcmBuffer);
|
|
// console.debug("readFrames: " + (frames - read) + ", read: " + pcmBuffer.byteLength + ", payload: " + payload.length);
|
|
// read += (pcmBuffer.byteLength / this.sampleFormat.frameSize());
|
|
for (let i = 0; i < payload.length; i += 2) {
|
|
read++;
|
|
left[pos] = (payload[i] / 32768); // * volume;
|
|
right[pos] = (payload[i + 1] / 32768); // * volume;
|
|
if ((everyN != 0) && (read % everyN == 0)) {
|
|
if (addFrames > 0) {
|
|
pos--;
|
|
}
|
|
else {
|
|
left[pos + 1] = left[pos];
|
|
right[pos + 1] = right[pos];
|
|
pos++;
|
|
// console.log("Add: " + pos);
|
|
}
|
|
}
|
|
pos++;
|
|
}
|
|
if (pcmChunk.isEndOfChunk()) {
|
|
this.chunk = this.chunks.shift();
|
|
}
|
|
}
|
|
if (addFrames != 0)
|
|
console.debug("Pos: " + pos + ", frames: " + frames + ", add: " + addFrames + ", everyN: " + everyN);
|
|
if (read == readFrames)
|
|
read = frames;
|
|
}
|
|
}
|
|
if (read < frames) {
|
|
console.log("Failed to get chunk, read: " + read + "/" + frames + ", chunks left: " + this.chunks.length);
|
|
left.fill(0, pos);
|
|
right.fill(0, pos);
|
|
}
|
|
// copyToChannel is not supported by Safari
|
|
buffer.getChannelData(0).set(left);
|
|
buffer.getChannelData(1).set(right);
|
|
}
|
|
}
|
|
class TimeProvider {
|
|
constructor(ctx = undefined) {
|
|
this.diffBuffer = new Array();
|
|
this.diff = 0;
|
|
if (ctx) {
|
|
this.setAudioContext(ctx);
|
|
}
|
|
}
|
|
setAudioContext(ctx) {
|
|
this.ctx = ctx;
|
|
this.reset();
|
|
}
|
|
reset() {
|
|
this.diffBuffer.length = 0;
|
|
this.diff = 0;
|
|
}
|
|
setDiff(c2s, s2c) {
|
|
if (this.now() == 0) {
|
|
this.reset();
|
|
}
|
|
else {
|
|
if (this.diffBuffer.push((c2s - s2c) / 2) > 100)
|
|
this.diffBuffer.shift();
|
|
let sorted = [...this.diffBuffer];
|
|
sorted.sort();
|
|
this.diff = sorted[Math.floor(sorted.length / 2)];
|
|
}
|
|
// console.debug("c2s: " + c2s.toFixed(2) + ", s2c: " + s2c.toFixed(2) + ", diff: " + this.diff.toFixed(2) + ", now: " + this.now().toFixed(2) + ", server.now: " + this.serverNow().toFixed(2) + ", win.now: " + window.performance.now().toFixed(2));
|
|
// console.log("now: " + this.now() + "\t" + this.now() + "\t" + this.now());
|
|
}
|
|
now() {
|
|
if (!this.ctx) {
|
|
return window.performance.now();
|
|
}
|
|
else {
|
|
// Use the more accurate getOutputTimestamp if available, fallback to ctx.currentTime otherwise.
|
|
const contextTime = !!this.ctx.getOutputTimestamp ? this.ctx.getOutputTimestamp().contextTime : undefined;
|
|
return (contextTime !== undefined ? contextTime : this.ctx.currentTime) * 1000;
|
|
}
|
|
}
|
|
nowSec() {
|
|
return this.now() / 1000;
|
|
}
|
|
serverNow() {
|
|
return this.serverTime(this.now());
|
|
}
|
|
serverTime(localTimeMs) {
|
|
return localTimeMs + this.diff;
|
|
}
|
|
}
|
|
class SampleFormat {
|
|
constructor() {
|
|
this.rate = 48000;
|
|
this.channels = 2;
|
|
this.bits = 16;
|
|
}
|
|
msRate() {
|
|
return this.rate / 1000;
|
|
}
|
|
toString() {
|
|
return this.rate + ":" + this.bits + ":" + this.channels;
|
|
}
|
|
sampleSize() {
|
|
if (this.bits == 24) {
|
|
return 4;
|
|
}
|
|
return this.bits / 8;
|
|
}
|
|
frameSize() {
|
|
return this.channels * this.sampleSize();
|
|
}
|
|
durationMs(bytes) {
|
|
return (bytes / this.frameSize()) * this.msRate();
|
|
}
|
|
}
|
|
class Decoder {
|
|
setHeader(_buffer) {
|
|
return new SampleFormat();
|
|
}
|
|
decode(_chunk) {
|
|
return null;
|
|
}
|
|
}
|
|
class OpusDecoder extends Decoder {
|
|
setHeader(buffer) {
|
|
let view = new DataView(buffer);
|
|
let ID_OPUS = 0x4F505553;
|
|
if (buffer.byteLength < 12) {
|
|
console.error("Opus header too small: " + buffer.byteLength);
|
|
return null;
|
|
}
|
|
else if (view.getUint32(0, true) != ID_OPUS) {
|
|
console.error("Opus header too small: " + buffer.byteLength);
|
|
return null;
|
|
}
|
|
let format = new SampleFormat();
|
|
format.rate = view.getUint32(4, true);
|
|
format.bits = view.getUint16(8, true);
|
|
format.channels = view.getUint16(10, true);
|
|
console.log("Opus samplerate: " + format.toString());
|
|
return format;
|
|
}
|
|
decode(_chunk) {
|
|
return null;
|
|
}
|
|
}
|
|
class FlacDecoder extends Decoder {
|
|
constructor() {
|
|
super();
|
|
this.header = null;
|
|
this.cacheInfo = { isCachedChunk: false, cachedBlocks: 0 };
|
|
this.decoder = Flac.create_libflac_decoder(true);
|
|
if (this.decoder) {
|
|
let init_status = Flac.init_decoder_stream(this.decoder, this.read_callback_fn.bind(this), this.write_callback_fn.bind(this), this.error_callback_fn.bind(this), this.metadata_callback_fn.bind(this), false);
|
|
console.log("Flac init: " + init_status);
|
|
Flac.setOptions(this.decoder, { analyseSubframes: true, analyseResiduals: true });
|
|
}
|
|
this.sampleFormat = new SampleFormat();
|
|
this.flacChunk = new ArrayBuffer(0);
|
|
// this.pcmChunk = new PcmChunkMessage();
|
|
// Flac.setOptions(this.decoder, {analyseSubframes: analyse_frames, analyseResiduals: analyse_residuals});
|
|
// flac_ok &= init_status == 0;
|
|
// console.log("flac init : " + flac_ok);//DEBUG
|
|
}
|
|
decode(chunk) {
|
|
// console.log("Flac decode: " + chunk.payload.byteLength);
|
|
this.flacChunk = chunk.payload.slice(0);
|
|
this.pcmChunk = chunk;
|
|
this.pcmChunk.clearPayload();
|
|
this.cacheInfo = { cachedBlocks: 0, isCachedChunk: true };
|
|
// console.log("Flac len: " + this.flacChunk.byteLength);
|
|
while (this.flacChunk.byteLength && Flac.FLAC__stream_decoder_process_single(this.decoder)) {
|
|
Flac.FLAC__stream_decoder_get_state(this.decoder);
|
|
// let state = Flac.FLAC__stream_decoder_get_state(this.decoder);
|
|
// console.log("State: " + state);
|
|
}
|
|
// console.log("Pcm payload: " + this.pcmChunk!.payloadSize());
|
|
if (this.cacheInfo.cachedBlocks > 0) {
|
|
let diffMs = this.cacheInfo.cachedBlocks / this.sampleFormat.msRate();
|
|
// console.log("Cached: " + this.cacheInfo.cachedBlocks + ", " + diffMs + "ms");
|
|
this.pcmChunk.timestamp.setMilliseconds(this.pcmChunk.timestamp.getMilliseconds() - diffMs);
|
|
}
|
|
return this.pcmChunk;
|
|
}
|
|
read_callback_fn(bufferSize) {
|
|
// console.log(' decode read callback, buffer bytes max=', bufferSize);
|
|
if (this.header) {
|
|
console.log(" header: " + this.header.byteLength);
|
|
let data = new Uint8Array(this.header);
|
|
this.header = null;
|
|
return { buffer: data, readDataLength: data.byteLength, error: false };
|
|
}
|
|
else if (this.flacChunk) {
|
|
// console.log(" flacChunk: " + this.flacChunk.byteLength);
|
|
// a fresh read => next call to write will not be from cached data
|
|
this.cacheInfo.isCachedChunk = false;
|
|
let data = new Uint8Array(this.flacChunk.slice(0, Math.min(bufferSize, this.flacChunk.byteLength)));
|
|
this.flacChunk = this.flacChunk.slice(data.byteLength);
|
|
return { buffer: data, readDataLength: data.byteLength, error: false };
|
|
}
|
|
return { buffer: new Uint8Array(0), readDataLength: 0, error: false };
|
|
}
|
|
write_callback_fn(data, frameInfo) {
|
|
// console.log(" write frame metadata: " + frameInfo + ", len: " + data.length);
|
|
if (this.cacheInfo.isCachedChunk) {
|
|
// there was no call to read, so it's some cached data
|
|
this.cacheInfo.cachedBlocks += frameInfo.blocksize;
|
|
}
|
|
let payload = new ArrayBuffer((frameInfo.bitsPerSample / 8) * frameInfo.channels * frameInfo.blocksize);
|
|
let view = new DataView(payload);
|
|
for (let channel = 0; channel < frameInfo.channels; ++channel) {
|
|
let channelData = new DataView(data[channel].buffer, 0, data[channel].buffer.byteLength);
|
|
// console.log("channelData: " + channelData.byteLength + ", blocksize: " + frameInfo.blocksize);
|
|
for (let i = 0; i < frameInfo.blocksize; ++i) {
|
|
view.setInt16(2 * (frameInfo.channels * i + channel), channelData.getInt16(2 * i, true), true);
|
|
}
|
|
}
|
|
this.pcmChunk.addPayload(payload);
|
|
// console.log("write: " + payload.byteLength + ", len: " + this.pcmChunk!.payloadSize());
|
|
}
|
|
/** @memberOf decode */
|
|
metadata_callback_fn(data) {
|
|
console.info('meta data: ', data);
|
|
// let view = new DataView(data);
|
|
this.sampleFormat.rate = data.sampleRate;
|
|
this.sampleFormat.channels = data.channels;
|
|
this.sampleFormat.bits = data.bitsPerSample;
|
|
console.log("metadata_callback_fn, sampleformat: " + this.sampleFormat.toString());
|
|
}
|
|
/** @memberOf decode */
|
|
error_callback_fn(err, errMsg) {
|
|
console.error('decode error callback', err, errMsg);
|
|
}
|
|
setHeader(buffer) {
|
|
this.header = buffer.slice(0);
|
|
Flac.FLAC__stream_decoder_process_until_end_of_metadata(this.decoder);
|
|
return this.sampleFormat;
|
|
}
|
|
}
|
|
class PlayBuffer {
|
|
constructor(buffer, playTime, source, destination) {
|
|
this.num = 0;
|
|
this.buffer = buffer;
|
|
this.playTime = playTime;
|
|
this.source = source;
|
|
this.source.buffer = this.buffer;
|
|
this.source.connect(destination);
|
|
this.onended = (_playBuffer) => { };
|
|
}
|
|
start() {
|
|
this.source.onended = () => {
|
|
this.onended(this);
|
|
};
|
|
this.source.start(this.playTime);
|
|
}
|
|
}
|
|
class PcmDecoder extends Decoder {
|
|
setHeader(buffer) {
|
|
let sampleFormat = new SampleFormat();
|
|
let view = new DataView(buffer);
|
|
sampleFormat.channels = view.getUint16(22, true);
|
|
sampleFormat.rate = view.getUint32(24, true);
|
|
sampleFormat.bits = view.getUint16(34, true);
|
|
return sampleFormat;
|
|
}
|
|
decode(chunk) {
|
|
return chunk;
|
|
}
|
|
}
|
|
class SnapStream {
|
|
constructor(baseUrl) {
|
|
this.playTime = 0;
|
|
this.msgId = 0;
|
|
this.bufferDurationMs = 80; // 0;
|
|
this.bufferFrameCount = 3844; // 9600; // 2400;//8192;
|
|
this.syncHandle = -1;
|
|
// ageBuffer: Array<number>;
|
|
this.audioBuffers = new Array();
|
|
this.freeBuffers = new Array();
|
|
// median: number = 0;
|
|
this.audioBufferCount = 3;
|
|
this.bufferMs = 1000;
|
|
this.bufferNum = 0;
|
|
this.latency = 0;
|
|
this.baseUrl = baseUrl;
|
|
this.timeProvider = new TimeProvider();
|
|
if (this.setupAudioContext()) {
|
|
this.connect();
|
|
}
|
|
else {
|
|
alert("Sorry, but the Web Audio API is not supported by your browser");
|
|
}
|
|
}
|
|
setupAudioContext() {
|
|
let AudioContext = window.AudioContext // Default
|
|
|| window.webkitAudioContext // Safari and old versions of Chrome
|
|
|| false;
|
|
if (AudioContext) {
|
|
let options;
|
|
options = { latencyHint: "playback", sampleRate: this.sampleFormat ? this.sampleFormat.rate : undefined };
|
|
const chromeVersion = getChromeVersion();
|
|
if ((chromeVersion !== null && chromeVersion < 55) || !window.AudioContext) {
|
|
// Some older browsers won't decode the stream if options are provided.
|
|
options = undefined;
|
|
}
|
|
this.ctx = new AudioContext(options);
|
|
this.gainNode = this.ctx.createGain();
|
|
this.gainNode.connect(this.ctx.destination);
|
|
}
|
|
else {
|
|
// Web Audio API is not supported
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
connect() {
|
|
this.streamsocket = new WebSocket(this.baseUrl + '/stream');
|
|
this.streamsocket.binaryType = "arraybuffer";
|
|
this.streamsocket.onmessage = (ev) => this.onMessage(ev);
|
|
this.streamsocket.onopen = () => {
|
|
console.log("on open");
|
|
let hello = new HelloMessage();
|
|
hello.mac = "00:00:00:00:00:00";
|
|
hello.arch = "web";
|
|
hello.os = navigator.platform;
|
|
hello.hostname = "Snapweb client";
|
|
hello.uniqueId = getPersistentValue("uniqueId", uuidv4());
|
|
this.sendMessage(hello);
|
|
this.syncTime();
|
|
this.syncHandle = window.setInterval(() => this.syncTime(), 1000);
|
|
};
|
|
this.streamsocket.onerror = (ev) => { console.error('error:', ev); };
|
|
this.streamsocket.onclose = () => {
|
|
window.clearInterval(this.syncHandle);
|
|
console.info('connection lost, reconnecting in 1s');
|
|
setTimeout(() => this.connect(), 1000);
|
|
};
|
|
}
|
|
onMessage(msg) {
|
|
let view = new DataView(msg.data);
|
|
let type = view.getUint16(0, true);
|
|
if (type == 1) {
|
|
let codec = new CodecMessage(msg.data);
|
|
console.log("Codec: " + codec.codec);
|
|
if (codec.codec == "flac") {
|
|
this.decoder = new FlacDecoder();
|
|
}
|
|
else if (codec.codec == "pcm") {
|
|
this.decoder = new PcmDecoder();
|
|
}
|
|
else if (codec.codec == "opus") {
|
|
this.decoder = new OpusDecoder();
|
|
alert("Codec not supported: " + codec.codec);
|
|
}
|
|
else {
|
|
alert("Codec not supported: " + codec.codec);
|
|
}
|
|
if (this.decoder) {
|
|
this.sampleFormat = this.decoder.setHeader(codec.payload);
|
|
console.log("Sampleformat: " + this.sampleFormat.toString());
|
|
if ((this.sampleFormat.channels != 2) || (this.sampleFormat.bits != 16)) {
|
|
alert("Stream must be stereo with 16 bit depth, actual format: " + this.sampleFormat.toString());
|
|
}
|
|
else {
|
|
if (this.bufferDurationMs != 0) {
|
|
this.bufferFrameCount = Math.floor(this.bufferDurationMs * this.sampleFormat.msRate());
|
|
}
|
|
if (window.AudioContext) {
|
|
// we are not using webkitAudioContext, so it's safe to setup a new AudioContext with the new samplerate
|
|
// since this code is not triggered by direct user input, we cannt create a webkitAudioContext here
|
|
this.stopAudio();
|
|
this.setupAudioContext();
|
|
}
|
|
this.ctx.resume();
|
|
this.timeProvider.setAudioContext(this.ctx);
|
|
this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100;
|
|
// this.timeProvider = new TimeProvider(this.ctx);
|
|
this.stream = new AudioStream(this.timeProvider, this.sampleFormat, this.bufferMs);
|
|
this.latency = (this.ctx.baseLatency !== undefined ? this.ctx.baseLatency : 0) + (this.ctx.outputLatency !== undefined ? this.ctx.outputLatency : 0);
|
|
console.log("Base latency: " + this.ctx.baseLatency + ", output latency: " + this.ctx.outputLatency + ", latency: " + this.latency);
|
|
this.play();
|
|
}
|
|
}
|
|
}
|
|
else if (type == 2) {
|
|
let pcmChunk = new PcmChunkMessage(msg.data, this.sampleFormat);
|
|
if (this.decoder) {
|
|
let decoded = this.decoder.decode(pcmChunk);
|
|
if (decoded) {
|
|
this.stream.addChunk(decoded);
|
|
}
|
|
}
|
|
}
|
|
else if (type == 3) {
|
|
this.serverSettings = new ServerSettingsMessage(msg.data);
|
|
this.gainNode.gain.value = this.serverSettings.muted ? 0 : this.serverSettings.volumePercent / 100;
|
|
this.bufferMs = this.serverSettings.bufferMs - this.serverSettings.latency;
|
|
console.log("ServerSettings bufferMs: " + this.serverSettings.bufferMs + ", latency: " + this.serverSettings.latency + ", volume: " + this.serverSettings.volumePercent + ", muted: " + this.serverSettings.muted);
|
|
}
|
|
else if (type == 4) {
|
|
if (this.timeProvider) {
|
|
let time = new TimeMessage(msg.data);
|
|
this.timeProvider.setDiff(time.latency.getMilliseconds(), this.timeProvider.now() - time.sent.getMilliseconds());
|
|
}
|
|
// console.log("Time sec: " + time.latency.sec + ", usec: " + time.latency.usec + ", diff: " + this.timeProvider.diff);
|
|
}
|
|
else {
|
|
console.info("Message not handled, type: " + type);
|
|
}
|
|
}
|
|
sendMessage(msg) {
|
|
msg.sent = new Tv(0, 0);
|
|
msg.sent.setMilliseconds(this.timeProvider.now());
|
|
msg.id = ++this.msgId;
|
|
if (this.streamsocket.readyState == this.streamsocket.OPEN) {
|
|
this.streamsocket.send(msg.serialize());
|
|
}
|
|
}
|
|
syncTime() {
|
|
let t = new TimeMessage();
|
|
t.latency.setMilliseconds(this.timeProvider.now());
|
|
this.sendMessage(t);
|
|
// console.log("prepareSource median: " + Math.round(this.median * 10) / 10);
|
|
}
|
|
stopAudio() {
|
|
// if (this.ctx) {
|
|
// this.ctx.close();
|
|
// }
|
|
this.ctx.suspend();
|
|
while (this.audioBuffers.length > 0) {
|
|
let buffer = this.audioBuffers.pop();
|
|
buffer.onended = () => { };
|
|
buffer.source.stop();
|
|
}
|
|
while (this.freeBuffers.length > 0) {
|
|
this.freeBuffers.pop();
|
|
}
|
|
}
|
|
stop() {
|
|
window.clearInterval(this.syncHandle);
|
|
this.stopAudio();
|
|
if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(this.streamsocket.readyState)) {
|
|
this.streamsocket.onclose = () => { };
|
|
this.streamsocket.close();
|
|
}
|
|
}
|
|
play() {
|
|
this.playTime = this.timeProvider.nowSec() + 0.1;
|
|
for (let i = 1; i <= this.audioBufferCount; ++i) {
|
|
this.playNext();
|
|
}
|
|
}
|
|
playNext() {
|
|
let buffer = this.freeBuffers.pop() || this.ctx.createBuffer(this.sampleFormat.channels, this.bufferFrameCount, this.sampleFormat.rate);
|
|
let playTimeMs = (this.playTime + this.latency) * 1000 - this.bufferMs;
|
|
this.stream.getNextBuffer(buffer, playTimeMs);
|
|
let source = this.ctx.createBufferSource();
|
|
let playBuffer = new PlayBuffer(buffer, this.playTime, source, this.gainNode);
|
|
this.audioBuffers.push(playBuffer);
|
|
playBuffer.num = ++this.bufferNum;
|
|
playBuffer.onended = (buffer) => {
|
|
// let diff = this.timeProvider.nowSec() - buffer.playTime;
|
|
this.freeBuffers.push(this.audioBuffers.splice(this.audioBuffers.indexOf(buffer), 1)[0].buffer);
|
|
// console.debug("PlayBuffer " + playBuffer.num + " ended after: " + (diff * 1000) + ", in flight: " + this.audioBuffers.length);
|
|
this.playNext();
|
|
};
|
|
playBuffer.start();
|
|
this.playTime += this.bufferFrameCount / this.sampleFormat.rate;
|
|
}
|
|
}
|
|
//# sourceMappingURL=snapstream.js.map
|