Files
pai-companion-voice-widget-…/www/app.js
nonna 2c369d74b1 Initial Neutralino.js POC for kokoro-widget
Lightweight alternative to the Electron build (~5MB vs ~300MB).
Uses system webview (WebKit2GTK on Linux, WKWebView on macOS,
WebView2 on Windows). Same UI and feature set as the Electron build:
WebSocket audio client, volume control, host/port settings, tray icon,
hide-to-tray on close, CLI args (--host, --port, --volume).

Note: Linux requires libwebkit2gtk-4.0 or libwebkit2gtk-4.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 00:44:45 +00:00

335 lines
9.7 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 after Neutralino is ready
Neutralino.init();
document.addEventListener('DOMContentLoaded', init);