Initial commit, basic functionality in place.
This commit is contained in:
263
renderer/renderer.js
Normal file
263
renderer/renderer.js
Normal file
@@ -0,0 +1,263 @@
|
||||
'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);
|
||||
Reference in New Issue
Block a user