264 lines
7.8 KiB
JavaScript
264 lines
7.8 KiB
JavaScript
'use strict';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
let ws = null;
|
|
let config = {}; // populated from IPC in init() — no hardcoded defaults here
|
|
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 — populated after DOMContentLoaded
|
|
let statusDot, statusText, speakingIndicator, lastMessage;
|
|
let volumeSlider, hostInput, portInput, saveBtn;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebSocket URL
|
|
// ---------------------------------------------------------------------------
|
|
function getWsUrl() {
|
|
return `ws://${config.host}:${config.port}/stream`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status display
|
|
// ---------------------------------------------------------------------------
|
|
function setStatus(state, text) {
|
|
statusDot.className = 'status-dot ' + state;
|
|
statusText.textContent = text;
|
|
}
|
|
|
|
function setSpeaking(active) {
|
|
if (active) {
|
|
speakingIndicator.classList.add('active');
|
|
} else {
|
|
speakingIndicator.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebSocket connection
|
|
// ---------------------------------------------------------------------------
|
|
function connect() {
|
|
// Clean up any existing connection
|
|
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 = () => {
|
|
everConnected = true;
|
|
// Socket open but no data yet — show a distinct waiting state
|
|
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) {
|
|
// First real data confirms the server is live
|
|
setStatus('connected', 'Connected');
|
|
if (audioQueue.length < AUDIO_QUEUE_MAX) {
|
|
audioQueue.push(event.data);
|
|
if (!isPlaying) playNext();
|
|
} else {
|
|
console.warn('Audio queue full — dropping incoming frame');
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
ws = null;
|
|
if (everConnected) {
|
|
// Was previously connected — show that we lost it
|
|
setStatus('error', 'Disconnected — retrying...');
|
|
} else {
|
|
// Never connected yet — stay in the neutral "Connecting..." state
|
|
// so there is no blink during the retry loop
|
|
setStatus('connecting', 'Connecting...');
|
|
}
|
|
scheduleReconnect();
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
// onclose always fires after onerror — let onclose handle the status
|
|
// update so there is no intermediate blink from this handler.
|
|
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) {
|
|
console.error(`Audio failed ${AUDIO_ERROR_LIMIT} times in a row — stopping playback`);
|
|
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); // prevent leak on play() rejection
|
|
consecutiveAudioErrors++;
|
|
if (consecutiveAudioErrors >= AUDIO_ERROR_LIMIT) {
|
|
console.error(`Audio failed ${AUDIO_ERROR_LIMIT} times in a row — stopping playback`);
|
|
isPlaying = false;
|
|
setSpeaking(false);
|
|
setStatus('error', 'Audio error');
|
|
audioQueue.length = 0;
|
|
return;
|
|
}
|
|
playNext();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Initialization
|
|
// ---------------------------------------------------------------------------
|
|
async function init() {
|
|
// Grab 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 config from main process (merged saved + CLI args)
|
|
try {
|
|
config = await window.kokoro.getConfig();
|
|
} catch (e) {
|
|
console.error('Failed to load config:', e);
|
|
}
|
|
|
|
// Populate UI with loaded values
|
|
volumeSlider.value = config.volume;
|
|
hostInput.value = config.host;
|
|
portInput.value = config.port;
|
|
|
|
// Volume slider: live update + debounce save
|
|
volumeSlider.addEventListener('input', () => {
|
|
config.volume = parseInt(volumeSlider.value, 10);
|
|
clearTimeout(volumeSaveTimer);
|
|
volumeSaveTimer = setTimeout(async () => {
|
|
try {
|
|
await window.kokoro.saveConfig({ volume: config.volume });
|
|
} catch (e) {
|
|
console.error('Failed to save volume:', e);
|
|
}
|
|
}, 500);
|
|
});
|
|
|
|
// Save button: update host/port, save, reconnect
|
|
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;
|
|
|
|
try {
|
|
config = await window.kokoro.saveConfig({ host: newHost, port: validPort });
|
|
|
|
// Visual feedback
|
|
saveBtn.textContent = 'Saved!';
|
|
saveBtn.classList.add('saved');
|
|
setTimeout(() => {
|
|
saveBtn.textContent = 'Save';
|
|
saveBtn.classList.remove('saved');
|
|
}, 1500);
|
|
|
|
// Reconnect with new settings
|
|
connect();
|
|
} catch (e) {
|
|
console.error('Failed to save config:', e);
|
|
}
|
|
});
|
|
|
|
// Connect to WebSocket
|
|
connect();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|