'use strict'; // --------------------------------------------------------------------------- // Config defaults and storage key // --------------------------------------------------------------------------- const CONFIG_DEFAULTS = { host: 'localhost', port: 8888, volume: 80 }; const STORAGE_KEY = 'kokoro-config'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let config = { ...CONFIG_DEFAULTS }; let ws = null; let everConnected = false; // true after first successful onopen let reconnectTimer = null; const audioQueue = []; const AUDIO_QUEUE_MAX = 25; let isPlaying = false; let consecutiveAudioErrors = 0; const AUDIO_ERROR_LIMIT = 5; let volumeSaveTimer = null; // DOM refs let statusDot, statusText, speakingIndicator, lastMessage; let volumeSlider, hostInput, portInput, saveBtn; // --------------------------------------------------------------------------- // Config persistence via Neutralino storage // --------------------------------------------------------------------------- async function loadConfig() { try { const raw = await Neutralino.storage.getData(STORAGE_KEY); return Object.assign({}, CONFIG_DEFAULTS, JSON.parse(raw)); } catch { return { ...CONFIG_DEFAULTS }; } } async function saveConfig(partial) { config = Object.assign({}, config, partial); try { await Neutralino.storage.setData(STORAGE_KEY, JSON.stringify(config)); } catch (e) { console.error('Failed to save config:', e); } return config; } // --------------------------------------------------------------------------- // CLI argument parsing — Neutralino exposes args via NL_ARGS global // --------------------------------------------------------------------------- function parseCliArgs() { const overrides = {}; // NL_ARGS is a space-joined string of all args passed after -- const args = (typeof NL_ARGS !== 'undefined' ? NL_ARGS : '').split(' ').filter(Boolean); for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--host': if (args[i + 1]) overrides.host = args[++i]; break; case '--port': { const p = parseInt(args[++i], 10); if (!isNaN(p)) overrides.port = p; break; } case '--volume': { const v = parseInt(args[++i], 10); if (!isNaN(v)) overrides.volume = Math.min(100, Math.max(0, v)); break; } } } return overrides; } // --------------------------------------------------------------------------- // Tray setup // --------------------------------------------------------------------------- async function setupTray() { try { await Neutralino.os.setTray({ icon: '/www/icon.png', menuItems: [ { id: 'tray_show', text: 'Show' }, { id: 'tray_sep', text: '-' }, { id: 'tray_quit', text: 'Quit' }, ], }); } catch (e) { console.warn('Tray setup failed (may not be supported on this platform):', e); } } // Handle tray menu clicks Neutralino.events.on('trayMenuItemClicked', async (evt) => { switch (evt.detail.id) { case 'tray_show': await Neutralino.window.show(); await Neutralino.window.focus(); break; case 'tray_quit': await Neutralino.app.exit(); break; } }); // Intercept window close — hide to tray instead of quitting Neutralino.events.on('windowClose', async () => { await Neutralino.window.hide(); }); // --------------------------------------------------------------------------- // Status / speaking display // --------------------------------------------------------------------------- function setStatus(state, text) { statusDot.className = 'status-dot ' + state; statusText.textContent = text; } function setSpeaking(active) { speakingIndicator.classList.toggle('active', active); } // --------------------------------------------------------------------------- // WebSocket connection // --------------------------------------------------------------------------- function getWsUrl() { return `ws://${config.host}:${config.port}/stream`; } function connect() { if (ws) { ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; ws.close(); ws = null; } clearReconnectTimer(); // Only show "Connecting..." on the very first attempt. // Once we've ever connected, preserve "Disconnected — retrying..." until // onopen fires so there's no flicker during the reconnect loop. if (!everConnected) { setStatus('connecting', 'Connecting...'); } try { ws = new WebSocket(getWsUrl()); ws.binaryType = 'arraybuffer'; ws.onopen = () => { everConnected = true; setStatus('connected', 'Connected — waiting for audio'); }; ws.onmessage = (event) => { if (typeof event.data === 'string') { try { const msg = JSON.parse(event.data); if (msg.type === 'notification' && msg.message) { lastMessage.textContent = msg.message; setStatus('connected', 'Connected'); } } catch { // ignore malformed JSON } } else if (event.data instanceof ArrayBuffer) { setStatus('connected', 'Connected'); if (audioQueue.length < AUDIO_QUEUE_MAX) { audioQueue.push(event.data); if (!isPlaying) playNext(); } else { console.warn('Audio queue full — dropping frame'); } } }; ws.onclose = () => { ws = null; if (everConnected) { setStatus('error', 'Disconnected — retrying...'); } else { setStatus('connecting', 'Connecting...'); } scheduleReconnect(); }; ws.onerror = () => { if (ws) ws._hadError = true; }; } catch (e) { console.error('WebSocket connection failed:', e); setStatus('error', 'Connection error'); scheduleReconnect(); } } function scheduleReconnect() { clearReconnectTimer(); reconnectTimer = setTimeout(() => { if (ws === null) connect(); }, 3000); } function clearReconnectTimer() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } // --------------------------------------------------------------------------- // Audio playback queue // --------------------------------------------------------------------------- async function playNext() { if (audioQueue.length === 0) { isPlaying = false; setSpeaking(false); return; } isPlaying = true; setSpeaking(true); const buffer = audioQueue.shift(); let url = null; try { const blob = new Blob([buffer], { type: 'audio/mpeg' }); url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = Math.min(1, Math.max(0, config.volume / 100)); audio.onended = () => { URL.revokeObjectURL(url); consecutiveAudioErrors = 0; playNext(); }; audio.onerror = (e) => { console.error('Audio error:', e); URL.revokeObjectURL(url); consecutiveAudioErrors++; if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) { isPlaying = false; setSpeaking(false); setStatus('error', 'Audio error'); audioQueue.length = 0; return; } playNext(); }; await audio.play(); consecutiveAudioErrors = 0; } catch (e) { console.error('Audio playback failed:', e); if (url) URL.revokeObjectURL(url); consecutiveAudioErrors++; if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) { isPlaying = false; setSpeaking(false); setStatus('error', 'Audio error'); audioQueue.length = 0; return; } playNext(); } } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- async function init() { // Wire DOM refs statusDot = document.getElementById('statusDot'); statusText = document.getElementById('statusText'); speakingIndicator = document.getElementById('speakingIndicator'); lastMessage = document.getElementById('lastMessage'); volumeSlider = document.getElementById('volume'); hostInput = document.getElementById('hostInput'); portInput = document.getElementById('portInput'); saveBtn = document.getElementById('saveBtn'); // Load persisted config then apply CLI overrides (CLI does not persist) config = await loadConfig(); const cliOverrides = parseCliArgs(); if (cliOverrides.host !== undefined) config.host = cliOverrides.host; if (cliOverrides.port !== undefined) config.port = cliOverrides.port; if (cliOverrides.volume !== undefined) config.volume = cliOverrides.volume; // Populate UI volumeSlider.value = config.volume; hostInput.value = config.host; portInput.value = config.port; // Volume: live update + debounce save volumeSlider.addEventListener('input', () => { config.volume = parseInt(volumeSlider.value, 10); clearTimeout(volumeSaveTimer); volumeSaveTimer = setTimeout(() => saveConfig({ volume: config.volume }), 500); }); // Save button saveBtn.addEventListener('click', async () => { const newHost = hostInput.value.trim() || 'localhost'; const newPort = parseInt(portInput.value, 10); const validPort = (!isNaN(newPort) && newPort > 0 && newPort <= 65535) ? newPort : 8888; hostInput.value = newHost; portInput.value = validPort; await saveConfig({ host: newHost, port: validPort }); saveBtn.textContent = 'Saved!'; saveBtn.classList.add('saved'); setTimeout(() => { saveBtn.textContent = 'Save'; saveBtn.classList.remove('saved'); }, 1500); // Treat as fresh connection to the new server everConnected = false; connect(); }); // Draggable header try { await Neutralino.window.setDraggableRegion('dragRegion'); } catch (e) { console.warn('setDraggableRegion not supported:', e); } // Set up tray await setupTray(); // Connect to voice server connect(); } // Boot: wait for BOTH the DOM and the Neutralino runtime to be ready. // "ready" guarantees native APIs (storage, tray, window) are live. // DOMContentLoaded guarantees DOM refs exist. // We race neither — resolve both before calling init(). let domReady = false; let neuReady = false; function maybeInit() { if (domReady && neuReady) init(); } Neutralino.init(); Neutralino.events.on('ready', () => { neuReady = true; maybeInit(); }); document.addEventListener('DOMContentLoaded', () => { domReady = true; maybeInit(); });