/* eslint-disable max-classes-per-file */ // Modified for Iris // Original source https://github.com/badaix/snapcast const Flac = require('libflacjs/dist/libflac.js'); // declare window.webkitAudioContext for the ts compiler interface Window { webkitAudioContext: typeof AudioContext } function setCookie(key: string, value: string, exdays: number = -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: string, defaultValue: string = ""): string { 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(): number | null { const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); return raw ? parseInt(raw[2]) : null; } function uuidv4(): string { 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: number, usec: number) { this.sec = sec; this.usec = usec; } setMilliseconds(ms: number) { this.sec = Math.floor(ms / 1000); this.usec = Math.floor(ms * 1000) % 1000000; } getMilliseconds(): number { return this.sec * 1000 + this.usec / 1000; } sec: number = 0; usec: number = 0; } class BaseMessage { constructor(_buffer?: ArrayBuffer) { } deserialize(buffer: ArrayBuffer) { 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(): ArrayBuffer { 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; } type: number = 0; id: number = 0; refersTo: number = 0; received: Tv = new Tv(0, 0); sent: Tv = new Tv(0, 0); size: number = 0; } class CodecMessage extends BaseMessage { constructor(buffer?: ArrayBuffer) { super(buffer); this.payload = new ArrayBuffer(0); if (buffer) { this.deserialize(buffer); } this.type = 1; } deserialize(buffer: ArrayBuffer) { 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); } codec: string = ""; payload: ArrayBuffer; } class TimeMessage extends BaseMessage { constructor(buffer?: ArrayBuffer) { super(buffer); if (buffer) { this.deserialize(buffer); } this.type = 4; } deserialize(buffer: ArrayBuffer) { super.deserialize(buffer); let view = new DataView(buffer); this.latency = new Tv(view.getInt32(26, true), view.getInt32(30, true)); } serialize(): ArrayBuffer { 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; } latency: Tv = new Tv(0, 0); } class JsonMessage extends BaseMessage { constructor(buffer?: ArrayBuffer) { super(buffer); if (buffer) { this.deserialize(buffer); } } deserialize(buffer: ArrayBuffer) { 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(): ArrayBuffer { 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; } json: any; } class HelloMessage extends JsonMessage { constructor(buffer?: ArrayBuffer) { super(buffer); if (buffer) { this.deserialize(buffer); } this.type = 5; } deserialize(buffer: ArrayBuffer) { 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(): ArrayBuffer { 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(); } mac: string = ""; hostname: string = ""; version: string = "0.1.0"; clientName = "Snapweb"; os: string = ""; arch: string = "web"; instance: number = 1; uniqueId: string = ""; snapStreamProtocolVersion: number = 2; } class ServerSettingsMessage extends JsonMessage { constructor(buffer?: ArrayBuffer) { super(buffer); if (buffer) { this.deserialize(buffer); } this.type = 3; } deserialize(buffer: ArrayBuffer) { super.deserialize(buffer); this.bufferMs = this.json["bufferMs"]; this.latency = this.json["latency"]; this.volumePercent = this.json["volume"]; this.muted = this.json["muted"]; } serialize(): ArrayBuffer { this.json = { "bufferMs": this.bufferMs, "latency": this.latency, "volume": this.volumePercent, "muted": this.muted }; return super.serialize(); } bufferMs: number = 0; latency: number = 0; volumePercent: number = 0; muted: boolean = false; } class PcmChunkMessage extends BaseMessage { constructor(buffer: ArrayBuffer, sampleFormat: SampleFormat) { super(buffer); this.deserialize(buffer); this.sampleFormat = sampleFormat; this.type = 2; } deserialize(buffer: ArrayBuffer) { 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: number): ArrayBuffer { 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(): number { return (this.payloadSize() / this.sampleFormat.frameSize()); } isEndOfChunk(): boolean { return this.idx >= this.getFrameCount(); } startMs(): number { return this.timestamp.getMilliseconds() + 1000 * (this.idx / this.sampleFormat.rate); } duration(): number { return 1000 * ((this.getFrameCount() - this.idx) / this.sampleFormat.rate); } payloadSize(): number { return this.payload.byteLength; } clearPayload(): void { this.payload = new ArrayBuffer(0); } addPayload(buffer: ArrayBuffer) { 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; } timestamp: Tv = new Tv(0, 0); // payloadSize: number = 0; payload: ArrayBuffer = new ArrayBuffer(0); idx: number = 0; sampleFormat: SampleFormat; } class AudioStream { constructor(public timeProvider: TimeProvider, public sampleFormat: SampleFormat, public bufferMs: number) { } chunks: Array = new Array(); setVolume(percent: number, muted: boolean) { // 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: PcmChunkMessage) { 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: AudioBuffer, playTimeMs: number) { 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 as PcmChunkMessage).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 as PcmChunkMessage; 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); } // 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); // } // } chunk?: PcmChunkMessage = undefined; volume: number = 1; muted: boolean = false; lastLog: number = 0; // correctAfterXFrames: number = 0; } class TimeProvider { constructor(ctx: AudioContext | undefined = undefined) { if (ctx) { this.setAudioContext(ctx); } } setAudioContext(ctx: AudioContext) { this.ctx = ctx; this.reset(); } reset() { this.diffBuffer.length = 0; this.diff = 0; } setDiff(c2s: number, s2c: number) { 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: number) { return localTimeMs + this.diff; } diffBuffer: Array = new Array(); diff: number = 0; ctx: AudioContext | undefined; } class SampleFormat { rate: number = 48000; channels: number = 2; bits: number = 16; public msRate(): number { return this.rate / 1000; } public toString(): string { return this.rate + ":" + this.bits + ":" + this.channels; } public sampleSize(): number { if (this.bits == 24) { return 4; } return this.bits / 8; } public frameSize(): number { return this.channels * this.sampleSize(); } public durationMs(bytes: number) { return (bytes / this.frameSize()) * this.msRate(); } } class Decoder { setHeader(_buffer: ArrayBuffer): SampleFormat | null { return new SampleFormat(); } decode(_chunk: PcmChunkMessage): PcmChunkMessage | null { return null; } } class OpusDecoder extends Decoder { setHeader(buffer: ArrayBuffer): SampleFormat | null { 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: PcmChunkMessage): PcmChunkMessage | null { return null; } } class FlacDecoder extends Decoder { constructor() { super(); 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: PcmChunkMessage): PcmChunkMessage | null { // // 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: number): Flac.ReadResult | Flac.CompletedReadResult { // // 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: Array, frameInfo: Flac.BlockMetadata) { // // 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: number = 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: number = 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: any) { // 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: any, errMsg: any) { // console.error('decode error callback', err, errMsg); } setHeader(buffer: ArrayBuffer): SampleFormat | null { this.header = buffer.slice(0); Flac.FLAC__stream_decoder_process_until_end_of_metadata(this.decoder); return this.sampleFormat; } sampleFormat: SampleFormat; decoder: number; header: ArrayBuffer | null = null; flacChunk: ArrayBuffer; pcmChunk?: PcmChunkMessage; cacheInfo: { isCachedChunk: boolean, cachedBlocks: number } = { isCachedChunk: false, cachedBlocks: 0 }; } class PlayBuffer { constructor(buffer: AudioBuffer, playTime: number, source: AudioBufferSourceNode, destination: AudioNode) { this.buffer = buffer; this.playTime = playTime; this.source = source; this.source.buffer = this.buffer; this.source.connect(destination); this.onended = (_playBuffer: PlayBuffer) => { }; } public onended: (playBuffer: PlayBuffer) => void start() { this.source.onended = () => { this.onended(this); } this.source.start(this.playTime); } buffer: AudioBuffer; playTime: number; source: AudioBufferSourceNode; num: number = 0; } class PcmDecoder extends Decoder { setHeader(buffer: ArrayBuffer): SampleFormat | null { 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: PcmChunkMessage): PcmChunkMessage | null { return chunk; } } class SnapStream { constructor(baseUrl: string) { 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"); } } private setupAudioContext(): boolean { let AudioContext = window.AudioContext // Default || window.webkitAudioContext // Safari and old versions of Chrome || false; if (AudioContext) { let options: AudioContextOptions | undefined; 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; } private 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); } } private onMessage(msg: MessageEvent) { 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 as 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); } } private sendMessage(msg: BaseMessage) { 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()); } } private syncTime() { let t = new TimeMessage(); t.latency.setMilliseconds(this.timeProvider.now()); this.sendMessage(t); // // console.log("prepareSource median: " + Math.round(this.median * 10) / 10); } private 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(); } } public stop() { window.clearInterval(this.syncHandle); this.stopAudio(); if ([WebSocket.OPEN, WebSocket.CONNECTING].includes(this.streamsocket.readyState)) { this.streamsocket.onclose = () => { }; this.streamsocket.close(); } } public play() { this.playTime = this.timeProvider.nowSec() + 0.1; for (let i = 1; i <= this.audioBufferCount; ++i) { this.playNext(); } } public 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: PlayBuffer) => { // 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 as SampleFormat).rate; } baseUrl: string; streamsocket!: WebSocket; playTime: number = 0; msgId: number = 0; bufferDurationMs: number = 80; // 0; bufferFrameCount: number = 3844; // 9600; // 2400;//8192; syncHandle: number = -1; // ageBuffer: Array; audioBuffers: Array = new Array(); freeBuffers: Array = new Array(); timeProvider: TimeProvider; stream: AudioStream | undefined; ctx!: AudioContext; // | undefined; gainNode!: GainNode; serverSettings: ServerSettingsMessage | undefined; decoder: Decoder | undefined; sampleFormat: SampleFormat | undefined; // median: number = 0; audioBufferCount: number = 3; bufferMs: number = 1000; bufferNum: number = 0; latency: number = 0; } export { SnapStream, } export default { SnapStream, };