/* * Parts of this file are derived from 3LAS (Low Latency Live Audio Streaming) * by JoJoBond — https://github.com/JoJoBond/3LAS * Licensed under GPL-2.0 (https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) * */ 'use strict'; class AudioWsMp3Player { constructor() { this._audioCtx = null; this._gainNode = null; this._ws = null; this._dataBuffer = new Uint8Array(0); this._frames = []; this._frameStartIdx = -1; this._frameEndIdx = -1; this._frameSamples = 0; this._frameSampleRate = 0; this._timeBudget = 0; this._nextPlayTime = 0; this._volume = 1.0; this._watchdogTimer = null; this._shouldReconnect = false; this._decoding = false; this._lastDecodeTime = 0; } get Volume() { return this._volume; } set Volume(value) { this._volume = Number(value); if (this._gainNode && this._audioCtx) { const clamped = Math.max(1e-20, Math.min(1.0, this._volume)); this._gainNode.gain.cancelScheduledValues(this._audioCtx.currentTime); this._gainNode.gain.exponentialRampToValueAtTime(clamped, this._audioCtx.currentTime + 0.5); } } Start() { this._audioCtx = new (window.AudioContext || window.webkitAudioContext)(); this._gainNode = this._audioCtx.createGain(); this._gainNode.gain.value = Math.max(1e-20, Math.min(1.0, this._volume)); this._gainNode.connect(this._audioCtx.destination); this._nextPlayTime = 0; this._dataBuffer = new Uint8Array(0); this._frames = []; this._frameStartIdx = -1; this._frameEndIdx = -1; this._timeBudget = 0; this._shouldReconnect = true; this._lastDecodeTime = performance.now(); this._mobileUnmute(); this._connect(); } Reset() { this._shouldReconnect = false; this._stopWatchdog(); if (this._ws) { const ws = this._ws; this._ws = null; ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; ws.close(); } if (this._audioCtx) { this._audioCtx.close(); this._audioCtx = null; } this._gainNode = null; this._dataBuffer = new Uint8Array(0); this._frames = []; this._nextPlayTime = 0; this._timeBudget = 0; this._decoding = false; } // Mobile workaround - play silent stereo buffer to unblock AudioContext. _mobileUnmute() { if (!this._audioCtx) return; const gain = this._audioCtx.createGain(); gain.gain.value = 1.0; gain.connect(this._audioCtx.destination); const silentBuf = this._audioCtx.createBuffer(2, this._audioCtx.sampleRate, this._audioCtx.sampleRate); const src = this._audioCtx.createBufferSource(); src.onended = () => { src.disconnect(); gain.disconnect(); }; src.buffer = silentBuf; src.connect(gain); src.start(); } // Open the websocket for audio and set up handlers. _connect() { if (!this._shouldReconnect) return; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; this._ws = new WebSocket(`${protocol}//${location.host}${location.pathname}audio`); this._ws.binaryType = 'arraybuffer'; this._ws.onopen = () => { this._lastDecodeTime = performance.now(); this._startWatchdog(); if (this._audioCtx && this._audioCtx.state === 'suspended') { this._audioCtx.resume(); } this._mobileUnmute(); }; this._ws.onmessage = (event) => { const chunk = new Uint8Array(event.data); const merged = new Uint8Array(this._dataBuffer.length + chunk.length); merged.set(this._dataBuffer); merged.set(chunk, this._dataBuffer.length); this._dataBuffer = merged; this._extractAndSchedule(); }; this._ws.onclose = () => { this._stopWatchdog(); this._ws = null; if (this._shouldReconnect) { this._dataBuffer = new Uint8Array(0); this._frames = []; this._frameStartIdx = -1; this._frameEndIdx = -1; this._nextPlayTime = 0; this._decoding = false; setTimeout(() => this._connect(), 1000); } }; this._ws.onerror = () => { if (this._ws) this._ws.close(); }; } // Check every 3 seconds if the connection has died, and if so schedule a re-connect. _startWatchdog() { this._stopWatchdog(); this._watchdogTimer = setInterval(() => { if (!this._shouldReconnect) { this._stopWatchdog(); return; } if (performance.now() - this._lastDecodeTime > 2000) { this._stopWatchdog(); if (this._ws) { const ws = this._ws; this._ws = null; ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; ws.close(); } this._dataBuffer = new Uint8Array(0); this._frames = []; this._frameStartIdx = -1; this._frameEndIdx = -1; this._nextPlayTime = 0; this._decoding = false; setTimeout(() => this._connect(), 1000); } }, 3000); } // Clear any pre-existing re-connect check _stopWatchdog() { if (this._watchdogTimer !== null) { clearInterval(this._watchdogTimer); this._watchdogTimer = null; } } // Find an MP3 frame start and its length in the data buffer, by searching byte-by-byte for the MPEG sync word if the start byte is not known. _findFrame() { if (this._frameStartIdx < 0) { for (let i = 0; i + 1 < this._dataBuffer.length; i++) { if (this._dataBuffer[i] === 0xFF && (this._dataBuffer[i + 1] & 0xE0) === 0xE0) { this._frameStartIdx = i; break; } } } if (this._frameStartIdx >= 0 && this._frameEndIdx < 0) { if (this._frameStartIdx + 3 < this._dataBuffer.length) { const b1 = this._dataBuffer[this._frameStartIdx + 1]; const b2 = this._dataBuffer[this._frameStartIdx + 2]; const ver = (b1 & 0x18) >>> 3; const lyr = (b1 & 0x06) >>> 1; const pad = (b2 & 0x02) >>> 1; const brx = (b2 & 0xF0) >>> 4; const srx = (b2 & 0x0C) >>> 2; const bitrate = AudioWsMp3Player.MPEG_bitrates[ver][lyr][brx] * 1000; const samprate = AudioWsMp3Player.MPEG_srates[ver][srx]; const samples = AudioWsMp3Player.MPEG_frame_samples[ver][lyr]; const slotSize = AudioWsMp3Player.MPEG_slot_size[lyr]; if (bitrate > 0 && samprate > 0) { this._frameSamples = samples; this._frameSampleRate = samprate; this._frameEndIdx = this._frameStartIdx + Math.floor((samples / 8.0 * bitrate) / samprate + (pad ? slotSize : 0)); } else { this._frameStartIdx = -1; } } } } // Check if a complete frame boundary has been found and enough bytes have arrived in the buffer to extract. _canExtractFrame() { return this._frameStartIdx >= 0 && this._frameEndIdx >= 0 && this._frameEndIdx <= this._dataBuffer.length; } // Extract the MPEG audio frame from the buffer, and reset the data buffer indexes ready for the next frame. _extractFrame() { const frameData = new Uint8Array(this._dataBuffer.buffer.slice(this._frameStartIdx, this._frameEndIdx)); this._dataBuffer = this._frameEndIdx + 1 < this._dataBuffer.length ? new Uint8Array(this._dataBuffer.buffer.slice(this._frameEndIdx)) : new Uint8Array(0); this._frameStartIdx = 0; this._frameEndIdx = -1; return { data: frameData, sampleCount: this._frameSamples, sampleRate: this._frameSampleRate }; } // Central processing loop to find all frames within the incoming buffer data each time a websocket message is received. _extractAndSchedule() { if (!this._audioCtx) return; this._findFrame(); while (this._canExtractFrame()) { this._frames.push(this._extractFrame()); this._findFrame(); } if (this._decoding || this._frames.length < 3) return; const first = this._frames[0]; const last = this._frames[this._frames.length - 1]; const firstGranule = first.sampleCount / first.sampleRate / 2.0; const lastGranule = last.sampleCount / last.sampleRate / 2.0; let expectedTime = firstGranule + lastGranule; let bufLen = first.data.length + last.data.length; for (let i = 1; i < this._frames.length - 1; i++) { expectedTime += this._frames[i].sampleCount / this._frames[i].sampleRate; bufLen += this._frames[i].data.length; } if (this._nextPlayTime !== 0 && this._nextPlayTime + expectedTime <= this._audioCtx.currentTime) return; const decodeBuffer = new Uint8Array(bufLen); let offset = 0; for (const frame of this._frames) { decodeBuffer.set(frame.data, offset); offset += frame.data.length; } this._frames.splice(0, this._frames.length - 1); this._decoding = true; this._audioCtx.decodeAudioData( decodeBuffer.buffer, (decoded) => { this._decoding = false; this._onDecoded(decoded, expectedTime, firstGranule, lastGranule); this._extractAndSchedule(); }, () => { this._decoding = false; this._extractAndSchedule(); } ); } // Each time we call to decode, trim artefacts created by decoder cold start to stop audio glitches. _onDecoded(decoded, expectedTime, firstGranule, lastGranule) { if (!this._audioCtx || !this._gainNode) return; this._lastDecodeTime = performance.now(); const ua = navigator.userAgent.toLowerCase(); const isApple = /iphone|ipod/.test(ua) || /ipad/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) || /mac os x/.test(ua); const delta = 0.001; let sampleCount, sampleOffset = 0; if (expectedTime > decoded.duration) { sampleCount = decoded.length; sampleOffset = 0; this._timeBudget += expectedTime - decoded.duration; } else if (expectedTime < decoded.duration) { sampleCount = Math.ceil(expectedTime * decoded.sampleRate); const budgetSamples = this._timeBudget * decoded.sampleRate; if (budgetSamples > 1.0) { const extra = Math.min(budgetSamples, decoded.length - sampleCount); sampleCount += extra; this._timeBudget -= extra / decoded.sampleRate; } const diff = decoded.duration - expectedTime; if ((diff - firstGranule - lastGranule) >= -delta) { sampleOffset = Math.floor((decoded.length - sampleCount) / 2); } if (Math.abs(firstGranule - lastGranule) <= delta) { sampleOffset = isApple ? Math.floor((decoded.length - sampleCount) / 2) : decoded.length - sampleCount; } else if (Math.abs(diff - firstGranule) <= delta) { sampleOffset = decoded.length - sampleCount; } else if (Math.abs(diff - lastGranule) <= delta) { sampleOffset = 0; } else { sampleOffset = Math.floor((decoded.length - sampleCount) / 2); } } else { sampleCount = decoded.length; sampleOffset = 0; } const buf = this._audioCtx.createBuffer(decoded.numberOfChannels, sampleCount, decoded.sampleRate); for (let ch = 0; ch < decoded.numberOfChannels; ch++) { buf.getChannelData(ch).set(decoded.getChannelData(ch).subarray(sampleOffset, sampleOffset + sampleCount)); } this._scheduleBuffer(buf); } // Schedule a decoded buffer for playback at the correct AudioContext time. Reset if it's falled behind (on reconnect etc.). _scheduleBuffer(buffer) { if (!this._audioCtx || !this._gainNode) return; if (this._audioCtx.state === 'suspended') { this._audioCtx.resume(); } const INITIAL_OFFSET = 0.33; if (this._nextPlayTime === 0) { this._nextPlayTime = this._audioCtx.currentTime + INITIAL_OFFSET; } const duration = buffer.duration; if (this._nextPlayTime + duration <= this._audioCtx.currentTime) { this._nextPlayTime = this._audioCtx.currentTime + INITIAL_OFFSET; } let skipDuration = 0; if (this._audioCtx.currentTime >= this._nextPlayTime) { skipDuration = this._audioCtx.currentTime - this._nextPlayTime + 0.05; } if (skipDuration < duration) { const src = this._audioCtx.createBufferSource(); src.loop = false; src.buffer = buffer; src.onended = () => src.disconnect(); src.connect(this._gainNode); src.start(this._nextPlayTime + skipDuration, skipDuration); } this._nextPlayTime += duration; } } // MPEG bitrate table [version][layer][index] in kbps AudioWsMp3Player.MPEG_bitrates = [ [ // MPEG 2.5 [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0], [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0], [0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0] ], [ // Reserved [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] ], [ // MPEG 2 [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0], [0,8,16,24,32,40,48,56,64,80,96,112,128,144,160,0], [0,32,48,56,64,80,96,112,128,144,160,176,192,224,256,0] ], [ // MPEG 1 [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320,0], [0,32,48,56,64,80,96,112,128,160,192,224,256,320,384,0], [0,32,64,96,128,160,192,224,256,288,320,352,384,416,448,0] ] ]; // Sample rates [version][index] in Hz AudioWsMp3Player.MPEG_srates = [ [11025,12000,8000,0], [0,0,0,0], [22050,24000,16000,0], [44100,48000,32000,0] ]; // Samples per frame [version][layer] AudioWsMp3Player.MPEG_frame_samples = [ [0,576,1152,384], [0,0,0,0], [0,576,1152,384], [0,1152,1152,384] ]; // Slot size [layer]: Reserved, Layer3, Layer2, Layer1 AudioWsMp3Player.MPEG_slot_size = [0,1,1,4];