Files
pai-companion-voice-widget-…/www/app.js
nonna 92d79a7d45 Fix WebSocket never connecting due to init race condition
Neutralino.init() establishes an internal WebSocket asynchronously.
Calling storage.getData() before the "ready" event fires could stall
silently, meaning init() never reached connect(). Also, "ready" can
fire before DOMContentLoaded, so DOM refs would be null.

Fix: wait for both "ready" AND DOMContentLoaded before calling init(),
guaranteeing native APIs and DOM are both available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 00:26:41 +00:00

346 lines
10 KiB
JavaScript

'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 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();
setStatus('connecting', 'Connecting...');
try {
ws = new WebSocket(getWsUrl());
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
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 = () => {
const hadError = ws && ws._hadError;
ws = null;
setStatus(hadError ? 'error' : 'connecting',
hadError ? 'Disconnected — retrying...' : 'Reconnecting...');
scheduleReconnect();
};
ws.onerror = () => {
if (ws) ws._hadError = true;
setStatus('error', 'Connection error');
};
} 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);
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(); });