const box = document.getElementById('box'); const fromEl = document.getElementById('from'); const textEl = document.getElementById('text'); const token = location.pathname.split('/').filter(Boolean).pop(); const socket = io({ query: { role: 'overlay', token } }); const FADE_MS = 400; // matches the CSS transition duration on #box const queue = []; let current = null; // { id, text, from, durationMs, audio } let audioCtx = null; function getAudioCtx() { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); return audioCtx; } // Browsers suspend AudioContext until a user gesture happens on the page. // OBS's Browser Source doesn't enforce this, but for testing in a normal // tab, unlock it on the first click/keypress anywhere on the page. ['click', 'keydown'].forEach((eventName) => { document.addEventListener(eventName, () => { const ctx = getAudioCtx(); if (ctx.state === 'suspended') ctx.resume(); }, { once: true }); }); function playNotificationSound() { try { const ctx = getAudioCtx(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(880, ctx.currentTime); osc.frequency.setValueAtTime(1320, ctx.currentTime + 0.1); gain.gain.setValueAtTime(0.2, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.35); } catch (err) { console.error('Could not play notification sound:', err); } } function playSpeech(base64Audio, mimeType) { const audio = new Audio(`data:${mimeType};base64,${base64Audio}`); // Fire-and-forget: we don't gate queue timing on this element's 'ended' // event. edge-tts streams MP3 without a duration header (no Xing/VBRI // frame), and OBS's embedded CEF browser has been observed firing 'ended' // unreliably (sometimes early) for such streams — see durationMs below. audio.play().catch((err) => console.error('Could not play TTS audio:', err)); } function advanceQueue() { if (current || queue.length === 0) return; current = queue.shift(); fromEl.textContent = current.from || ''; fromEl.style.display = current.from ? 'block' : 'none'; textEl.textContent = current.text; // force reflow so the transition re-triggers even if a message was already visible void box.offsetWidth; box.classList.add('visible'); playNotificationSound(); if (current.audio) { playSpeech(current.audio.data, current.audio.mimeType); } // durationMs is computed server-side as max(streamer's configured display // time, estimated reading time for the TTS text) — see estimateSpeechMs in // server.js — so it's long enough to cover the actual audio playback // without relying on the browser telling us when the audio finished. setTimeout(() => { box.classList.remove('visible'); current = null; setTimeout(advanceQueue, FADE_MS); }, current.durationMs || 10000); } socket.on('display_message', (payload) => { queue.push({ id: payload.id, text: payload.text, from: payload.from, durationMs: payload.durationMs, audio: null, }); advanceQueue(); }); socket.on('display_audio', (payload) => { const audio = { data: payload.audio, mimeType: payload.mimeType }; if (current && current.id === payload.id) { playSpeech(audio.data, audio.mimeType); return; } const queued = queue.find((item) => item.id === payload.id); if (queued) queued.audio = audio; }); socket.on('invalid_link', () => { console.error('Invalid overlay link — check the URL from your dashboard.'); });