Files

1289 lines
39 KiB
HTML

<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta content="utf-8" http-equiv="encoding" />
<meta name="copyright" content="&copy; 2020 Steve Seguin" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="shortcut icon" href="favicon.svg" />
<title>Caption Overlay Window</title>
<meta name="title" content="CAPTION.Ninja: Overlay" />
<meta name="description" content="This is a free-to-use captioning tool for OBS. Speech-to-text is done using Machine Learning" />
<meta name="author" content="Steve Seguin" />
<style>
@font-face {
font-family: 'Cousine';
src: url('fonts/Cousine-Bold.ttf') format('truetype');
}
body {
margin:0;
padding:0 10px;
height:100%;
border: 0;
display: flex;
flex-direction: column-reverse;
position:absolute;
bottom:0;
overflow:hidden;
}
.output {
margin:0;
background-color: #0000;
color: white;
font-family: Cousine, monospace;
font-size: 3.2em;
line-height: 1.1em;
letter-spacing: 0.0em;
padding: 0em;
text-shadow: 0.05em 0.05em 0px rgba(0,0,0,1);
}
.output span {
background-color: black;
padding: 8px 8px 0px 8px;
margin:0;
max-width: 100%;
}
.output .dual-line {
display: flex;
flex-direction: column;
gap: 0.15em;
line-height: 1.05em;
padding: 0;
background-color: transparent;
}
.output .dual-translation {
font-size: 0.9em;
padding: 4px 8px 0px 8px;
}
.output .dual-original {
font-size: 0.65em;
opacity: 0.85;
letter-spacing: normal;
padding: 2px 8px 0px 8px;
}
a {
text-transform: none;
}
</style>
<script src="relay-config.js"></script><script src="private-relay-client.js"></script>
</head>
<body>
<div id="interm" class="output"></div>
<div id="output" class="output"></div>
<div id="status" style="display:none;"></div>
<script src="tts-integration.js"></script>
<script src="translation-providers.js"></script>
<script>
(function (w) {
w.URLSearchParams = w.URLSearchParams || function (searchString) {
var self = this;
self.searchString = searchString;
self.get = function (name) {
var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(self.searchString);
if (results == null) {
return null;
}
else {
return decodeURI(results[1]) || 0;
}
};
};
})(window);
var urlParams = new URLSearchParams(window.location.search);
let worker;
let modelRegistry;
let version;
let supportedFromCodes = {};
let supportedToCodes = {};
let currentTo = null;
const status = function(message){document.getElementById("status").innerText = message;}
const getLocaleFromLang = (lang) => {
if (!lang) return null;
if (lang.includes("-")) return lang;
// Common language to locale mappings
const localeMap = {
en: "en-US",
es: "es-ES",
fr: "fr-FR",
de: "de-DE",
it: "it-IT",
pt: "pt-PT",
ru: "ru-RU",
zh: "zh-CN",
ja: "ja-JP",
ko: "ko-KR"
};
return localeMap[lang.toLowerCase()] || `${lang}-${lang.toUpperCase()}`;
};
// Default system language
let currentLanguage = "en-US";
// Translation settings
const translationParams = ["translate", "lang", "ln"];
const hasTranslation = translationParams.some(param => urlParams.has(param));
const translationLang = hasTranslation ?
(translationParams.map(param => urlParams.get(param)).find(val => val) || "en") : false;
const langTo = translationLang ? translationLang.split("-")[0] : false;
const dualOverlay = langTo && (
urlParams.has("dual") ||
urlParams.has("dualoverlay") ||
urlParams.has("showboth") ||
urlParams.get("view") === "dual"
);
// Translation provider settings
const fallbackGoogleApiKey = urlParams.get("googlekey") || urlParams.get("gkey") || false;
const fallbackAutoDetect = urlParams.has("autodetect") || urlParams.get("fromlang") === "auto";
const forceLocal = urlParams.has("forcelocal");
const translationConfigBase = (window.CaptionTranslationProviders && window.CaptionTranslationProviders.resolveConfig)
? window.CaptionTranslationProviders.resolveConfig(urlParams, { forceLocal: forceLocal })
: {
provider: fallbackGoogleApiKey ? "google" : "local",
apiKey: fallbackGoogleApiKey || "",
googleApiKey: fallbackGoogleApiKey || "",
endpoint: "",
model: "",
forceLocal: forceLocal,
autoDetect: fallbackAutoDetect
};
const translationProvider = translationConfigBase.provider || "local";
const googleApiKey = translationConfigBase.googleApiKey || fallbackGoogleApiKey || false;
const autoDetectLang = Boolean(translationConfigBase.autoDetect);
// Context settings
const useContext = urlParams.has("context") || urlParams.has("fullcontext");
const contextSize = parseInt(urlParams.get("contextsize")) || 2;
let messageHistory = [];
// Translation cache for remote provider responses
const translationCache = new Map();
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
function preserveOriginal(meta, text, isInterim){
if (!dualOverlay || !meta || !text) {
return;
}
const key = isInterim ? "originalInterm" : "originalFinal";
if (!meta[key]) {
meta[key] = text;
}
}
// TTS settings initialization - only set if explicitly specified
const ttsParams = ["speech", "speak", "tts"];
const hasTTS = ttsParams.some(param => urlParams.has(param));
const ttsLang = hasTTS ?
(ttsParams.map(param => urlParams.get(param)).find(val => val)) : null;
const langs = { // https://github.com/mozilla/translate - MPL 2.0 - Mozilla
"bg": "Bulgarian",
"cs": "Czech",
"nl": "Dutch",
"en": "English",
"et": "Estonian",
"de": "German",
"fr": "French",
"is": "Icelandic",
"it": "Italian",
"nb": "Norwegian Bokmål",
"nn": "Norwegian Nynorsk",
"fa": "Persian",
"pl": "Polish",
"pt": "Portuguese",
"ru": "Russian",
"es": "Spanish",
"uk": "Ukrainian"
};
function updateURL(param, force=false) {
var para = param.split('=');
if (!(urlParams.has(para[0].toLowerCase()))){
if (history.pushState){
var arr = window.location.href.split('?');
var newurl;
if (arr.length > 1 && arr[1] !== '') {
newurl = window.location.href + '&' +param;
} else {
newurl = window.location.href + '?' +param;
}
window.history.pushState({path:newurl},'',newurl);
}
} else if (force){
if (history.pushState){
var href = new URL(window.location.href);
if (para.length==1){
href.searchParams.set(para[0].toLowerCase(), "");
} else {
href.searchParams.set(para[0].toLowerCase(), para[1]);
}
log(href.toString());
window.history.pushState({path:href.toString()},'',href.toString());
}
}
}
async function translateViaProvider(text, sourceLang, targetLang) {
if (!text.trim()) {
return null;
}
if (!window.CaptionTranslationProviders || !window.CaptionTranslationProviders.translateText) {
return null;
}
const useAutoDetect = autoDetectLang || sourceLang === "auto";
const requestConfig = Object.assign({}, translationConfigBase, {
autoDetect: useAutoDetect
});
if (!window.CaptionTranslationProviders.canUseRemoteProvider(requestConfig)) {
return null;
}
const cacheKey = window.CaptionTranslationProviders.buildCacheKey(
requestConfig,
text,
sourceLang,
targetLang,
useAutoDetect
);
if (!useAutoDetect) {
const cached = translationCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return cached.translation;
}
}
const result = await window.CaptionTranslationProviders.translateText(
requestConfig,
text,
sourceLang,
targetLang
);
if (!result || !result.translatedText) {
return null;
}
if (useAutoDetect && result.detectedSourceLanguage) {
console.log(`Auto-detected language: ${result.detectedSourceLanguage}`);
}
if (!useAutoDetect) {
translationCache.set(cacheKey, {
translation: result.translatedText,
timestamp: Date.now(),
detectedLang: result.detectedSourceLanguage || null
});
}
return result.translatedText;
}
// Clean up old cache entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of translationCache.entries()) {
if (now - value.timestamp > CACHE_DURATION) {
translationCache.delete(key);
}
}
}, 60 * 60 * 1000); // Run every hour
class TextToSpeech {
constructor(urlParams = new URLSearchParams(window.location.search)) {
this.speech = false;
this.premiumQueueTTS = [];
this.premiumQueueActive = false;
this.initializeSettings(urlParams);
this.initializeVoices();
this.initializeAudio();
}
initializeSettings(urlParams) {
this.settings = {
// Only set speechLang if explicitly provided in URL
speechLang: ttsLang ? getLocaleFromLang(ttsLang) : langTo ? getLocaleFromLang(langTo) : null,
volume: parseFloat(urlParams.get("volume")) || 1,
pitch: parseFloat(urlParams.get("pitch")) || 0,
rate: parseFloat(urlParams.get("rate")) || 1,
voiceLatency: parseInt(urlParams.get("latency")) || 4,
voiceGender: urlParams.get("gender") || false,
voiceName: urlParams.get("voice") || false,
google: {
apiKey: urlParams.get("ttskey") || urlParams.get("googlettskey") || false,
rate: urlParams.has("googlerate") ? parseFloat(urlParams.get("googlerate")) : 1,
pitch: urlParams.has("googlepitch") ? parseFloat(urlParams.get("googlepitch")) : 0,
audioProfile: urlParams.get("googleaudioprofile") || false,
voiceName: urlParams.get("voicegoogle") || false
},
elevenLabs: {
apiKey: urlParams.get("elevenlabskey") || false,
latency: urlParams.has("elevenlatency") ? parseInt(urlParams.get("elevenlatency")) : 4,
stability: urlParams.has("elevenstability") ? parseFloat(urlParams.get("elevenstability")) : 0.5,
similarity: urlParams.has("elevensimilarity") ? parseFloat(urlParams.get("elevensimilarity")) : 0.75,
style: urlParams.has("elevenstyle") ? parseFloat(urlParams.get("elevenstyle")) : 0.5,
speakingRate: urlParams.has("elevenrate") ? parseFloat(urlParams.get("elevenrate")) : 1.0,
speakerBoost: urlParams.has("elevenspeakerboost"),
voiceName: urlParams.get("voice11") || urlParams.get("elevenlabsvoice") || false,
model: urlParams.get("elevenlabsmodel") || "eleven_multilingual_v2"
},
speechify: {
apiKey: urlParams.get("speechifykey") || false,
speed: urlParams.has("speechifyspeed") ? parseFloat(urlParams.get("speechifyspeed")) : 1.0,
model: urlParams.get("speechifymodel") || 'simba-english',
voiceName: urlParams.get("voicespeechify") || false
}
};
if (urlParams.has("speech") || urlParams.has("speak") || urlParams.has("tts")) {
this.speech = true;
}
}
initializeVoices() {
this.voices = null;
this.voice = false;
try {
this.voices = window.speechSynthesis.getVoices();
if (!this.voices.length && this.isSafari()) {
console.warn("Safari doesn't fully support automatic TTS");
}
} catch (e) {
console.error("TTS not supported");
}
}
initializeAudio() {
if (this.settings.google.apiKey || this.settings.elevenLabs.apiKey || this.settings.speechify.apiKey) {
this.audio = document.createElement("audio");
this.audio.onended = () => this.finishedAudio();
}
}
createUI() {
const template = document.createElement('template');
template.innerHTML = `
<div class="tts-controls">
<button id="ttsToggle" class="tts-toggle">Toggle TTS</button>
<input type="range" id="volumeSlider" min="0" max="100" value="${this.settings.volume * 100}" />
</div>
`;
const style = document.createElement('style');
style.textContent = `
.tts-controls { display: flex; gap: 1em; align-items: center; }
.tts-toggle { padding: 0.5em 1em; }
`;
document.head.appendChild(style);
const ui = template.content.cloneNode(true);
ui.querySelector('#ttsToggle').addEventListener('click', () => this.toggleTTS());
ui.querySelector('#volumeSlider').addEventListener('input', (e) => {
this.settings.volume = e.target.value / 100;
});
return ui;
}
speak(text, allow = false) {
if ((!this.speech && !allow) || !text) return;
// Use explicitly set language or fall back to current language
const languageToUse = this.settings.speechLang || currentLanguage;
if (this.settings.google.apiKey) {
if (!this.premiumQueueActive) {
this.googleTTS(text, languageToUse);
} else {
this.premiumQueueTTS.push({text, language: languageToUse});
}
return;
}
// Similar updates for other TTS services
if (this.settings.elevenLabs.apiKey) {
if (!this.premiumQueueActive) {
this.elevenLabsTTS(text, languageToUse);
} else {
this.premiumQueueTTS.push({text, language: languageToUse});
}
return;
}
if (this.settings.speechify.apiKey) {
if (!this.premiumQueueActive) {
this.speechifyTTS(text, languageToUse);
} else {
this.premiumQueueTTS.push({text, language: languageToUse});
}
return;
}
this.useNativeTTS(text, languageToUse);
}
useNativeTTS(text, language) {
if (!this.voices && this.voices === null) return;
const speechInput = new SpeechSynthesisUtterance();
// Set language before voice selection
speechInput.lang = language;
if (!this.voice) {
this.selectVoice(language);
}
if (this.voice) {
speechInput.voice = this.voice;
}
speechInput.volume = this.settings.volume;
speechInput.rate = this.settings.rate;
speechInput.pitch = this.settings.pitch;
speechInput.text = text;
if (window.speechSynthesis) {
window.speechSynthesis.speak(speechInput);
this.updateUIState(true);
}
}
selectVoice(language) {
if (!this.voices || !this.voices.length) {
if (window.speechSynthesis) {
this.voices = window.speechSynthesis.getVoices();
}
}
if (this.voices) {
// Try to find voice matching name and language
if (this.settings.voiceName) {
this.voice = this.voices.find(v =>
v.name.toLowerCase().includes(this.settings.voiceName.toLowerCase()) &&
v.lang.toLowerCase().startsWith(language.split('-')[0].toLowerCase())
);
}
// If no voice found, try just matching language
if (!this.voice) {
this.voice = this.voices.find(v =>
v.lang.toLowerCase() === language.toLowerCase() ||
v.lang.split('-')[0].toLowerCase() === language.split('-')[0].toLowerCase()
);
}
// Fall back to first available voice if needed
if (!this.voice && this.voices.length) {
this.voice = this.voices[0];
}
}
}
googleTTS(text) {
this.premiumQueueActive = true;
const url = `https://texttospeech.googleapis.com/v1beta1/text:synthesize?key=${this.settings.google.apiKey}`;
const data = {
input: { text },
voice: {
languageCode: this.settings.speechLang.toLowerCase() || 'en-US',
name: this.settings.google.voiceName || "en-GB-Standard-A",
ssmlGender: this.settings.voiceGender ? this.settings.voiceGender.toUpperCase() : "FEMALE"
},
audioConfig: {
audioEncoding: "MP3",
speakingRate: this.settings.google.rate,
pitch: this.settings.google.pitch,
effectsProfileId: [this.settings.google.audioProfile || 'handset-class-device']
}
};
this.fetchAudio(url, {
headers: { "content-type": "application/json; charset=UTF-8" },
body: JSON.stringify(data),
method: "POST"
}, response => `data:audio/mp3;base64,${response.audioContent}`);
}
elevenLabsTTS(text) {
this.premiumQueueActive = true;
const voiceid = this.settings.elevenLabs.voiceName || "VR6AewLTigWG4xSOukaG";
const url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceid}/stream?optimize_streaming_latency=${this.settings.elevenLabs.latency}`;
const data = {
text,
model_id: this.settings.elevenLabs.model,
voice_settings: {
stability: this.settings.elevenLabs.stability,
similarity_boost: this.settings.elevenLabs.similarity,
style: this.settings.elevenLabs.style,
use_speaker_boost: this.settings.elevenLabs.speakerBoost,
speaking_rate: this.settings.elevenLabs.speakingRate
}
};
// ElevenLabs returns binary audio data, not JSON
fetch(url, {
headers: {
"content-type": "application/json",
"xi-api-key": this.settings.elevenLabs.apiKey,
accept: "*/*"
},
body: JSON.stringify(data),
method: "POST"
})
.then(response => response.blob())
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
if (!this.audio) {
this.audio = document.createElement("audio");
this.audio.onended = () => this.finishedAudio();
}
this.audio.src = blobUrl;
if (this.settings.volume) {
this.audio.volume = this.settings.volume;
}
return this.audio.play();
})
.catch(error => {
this.finishedAudio();
console.error("ElevenLabs TTS error:", error);
});
}
speechifyTTS(text) {
this.premiumQueueActive = true;
const url = "https://api.sws.speechify.com/v1/audio/speech";
const data = {
input: `<speak>${text}</speak>`,
voice_id: this.settings.speechify.voiceName || "henry",
model: this.settings.speechify.model,
audio_format: "mp3",
speed: this.settings.speechify.speed
};
this.fetchAudio(url, {
headers: {
"Authorization": `Bearer ${this.settings.speechify.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(data),
method: "POST"
}, response => `data:audio/mp3;base64,${response.audio_data}`);
}
async fetchAudio(url, options, processResponse, responseType = 'json') {
try {
const response = await fetch(url, options);
const data = responseType === 'blob' ? await response.blob() : await response.json();
if (!this.audio) {
this.audio = document.createElement("audio");
this.audio.onended = () => this.finishedAudio();
}
this.audio.src = await processResponse(data);
if (this.settings.volume) {
this.audio.volume = this.settings.volume;
}
try {
await this.audio.play();
} catch (e) {
this.finishedAudio();
console.error("User interaction required for audio playback");
}
} catch (error) {
this.finishedAudio();
console.error("Audio fetch error:", error);
}
}
finishedAudio() {
this.premiumQueueActive = false;
if (this.premiumQueueTTS.length) {
this.speak(this.premiumQueueTTS.shift());
}
this.updateUIState(false);
}
clearTTS() {
if (window.speechSynthesis && (window.speechSynthesis.pending || window.speechSynthesis.speaking)) {
window.speechSynthesis.cancel();
} else if (this.premiumQueueActive) {
this.premiumQueueTTS = [];
try {
if (this.audio) {
this.audio.pause();
}
} catch (e) {
console.error(e);
}
}
}
toggleTTS() {
if (window.speechSynthesis && (window.speechSynthesis.pending || window.speechSynthesis.speaking)) {
this.speech = false;
window.speechSynthesis.cancel();
} else if (this.premiumQueueActive) {
this.speech = false;
this.premiumQueueTTS = [];
try {
if (this.audio) {
this.audio.pause();
}
} catch (e) {
console.error(e);
}
this.premiumQueueActive = false;
} else {
this.speech = !this.speech;
}
this.updateUIState(this.speech);
}
updateUIState(isPlaying) {
const toggle = document.getElementById('ttsToggle');
if (toggle) {
toggle.textContent = isPlaying ? 'Stop TTS' : 'Start TTS';
toggle.classList.toggle('active', isPlaying);
}
}
isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}
}
const tts = new TextToSpeech();
// document.body.appendChild(tts.createUI());
if (langTo){
// Handle auto-detect: langFrom will be "auto" when auto-detection is enabled
var langFrom = autoDetectLang ? "auto" : (urlParams.get("fromlang") || "en");
// With auto-detect, we always need translation (provider determines source language)
var needsTranslation = autoDetectLang || (langFrom.split('-')[0].toLowerCase() !== langTo.split('-')[0].toLowerCase());
let remoteTranslationEnabled = (translationProvider !== "local") && !forceLocal;
if (translationProvider === "google" && remoteTranslationEnabled && !googleApiKey) {
remoteTranslationEnabled = false;
console.warn("Google provider selected without API key. Add &googlekey=YOUR_KEY or switch providers.");
status("Google provider requires API key");
}
// Only initialize local translation if remote provider is not active.
// Note: Local translation (Bergamot) does NOT support auto-detect.
if (needsTranslation && !remoteTranslationEnabled && !autoDetectLang) {
if (window.Worker) {
worker = new Worker("worker.js");
worker.postMessage(["import"]);
}
}
// Warn if auto-detect is enabled without a remote provider.
if (autoDetectLang && !remoteTranslationEnabled) {
console.warn("Auto-detect language requires a remote translation provider.");
status("Auto-detect requires remote provider");
}
var translateCall = async (text, meta, interm=false) => { // https://github.com/mozilla/translate - MPL 2.0 - Mozilla
if (!text.trim().length) return;
const isInterim = Boolean(interm);
if (dualOverlay && meta) {
const sourceText = isInterim ? (meta?.interm || text) : (meta?.final || text);
if (sourceText) {
preserveOriginal(meta, sourceText, isInterim);
}
}
const passThroughText = () => {
if (!meta){
document.getElementById("output").innerText = text;
} else if (meta.final){
processReply(meta, interm);
} else if (meta.interm){
processReply(meta, interm);
} else {
document.getElementById("output").innerText = text;
}
};
// Check if translation is needed (recalculate in case langFrom changed)
// With auto-detect, always attempt translation
needsTranslation = autoDetectLang || (langFrom.split('-')[0].toLowerCase() !== langTo.split('-')[0].toLowerCase());
// Pass through if same language (but not if using auto-detect)
if (!needsTranslation && !autoDetectLang) {
passThroughText();
return;
}
// Use remote translation provider if enabled.
if (remoteTranslationEnabled) {
try {
const translation = await translateViaProvider(text, langFrom, langTo);
if (translation) {
if (!meta){
document.getElementById("output").innerText = translation;
} else if (meta.final){
meta.final = translation;
processReply(meta, interm);
} else if (meta.interm){
meta.interm = translation;
processReply(meta, interm);
} else {
document.getElementById("output").innerText = translation;
}
return;
}
} catch (error) {
console.error("Remote translation failed, falling back to local:", error);
status(`Translation error: ${error.message}`);
// Fall back to local translation if available.
if (worker) {
const paragraphs = text.split("\n");
worker.postMessage(["translate", langFrom, langTo, paragraphs, meta, interm]);
return;
}
}
} else if (worker) {
// Use local translation
const paragraphs = text.split("\n");
worker.postMessage(["translate", langFrom, langTo, paragraphs, meta, interm]);
return;
}
// Safe fallback when translation is unavailable.
passThroughText();
};
if (worker) {
worker.onmessage = function (e) {
if (e.data[0] === "translate_reply" && e.data[1]) {
if (!e.data[2]){
document.getElementById("output").innerText = e.data[1].join("<br /><br />");
} else if (e.data[2].final){
e.data[2].final = e.data[1].join("<br /><br />");
processReply(e.data[2], (e.data[3]||false));
} else if (e.data[2].interm){
e.data[2].interm = e.data[1].join("<br /><br />");
processReply(e.data[2], (e.data[3]||false));
} else {
document.getElementById("output").innerText = e.data[1].join("<br /><br />");
}
} else if (e.data[0] === "load_model_reply" && e.data[1]) {
status(e.data[1]);
} else if (e.data[0] === "import_reply" && e.data[1]) {
modelRegistry = e.data[1];
version = e.data[2];
init();
}
};
}
var isSupported = (lngFrom, lngTo) => {
return true; //(`${lngFrom}${lngTo}` in modelRegistry) || ((`${lngFrom}en` in modelRegistry) && (`en${lngTo}` in modelRegistry))
}
var loadModel = () => {
// Recalculate if translation is needed
needsTranslation = langFrom.split('-')[0].toLowerCase() !== langTo.split('-')[0].toLowerCase();
if (needsTranslation && worker) {
status(`Installing model...`);
console.log(`Loading model '${langFrom}${langTo}'`);
worker.postMessage(["load_model", langFrom, langTo]);
} else if (!needsTranslation) {
console.log("Same language - no translation needed");
} else {
console.log("No model loaded yet");
}
};
function init() { // https://github.com/mozilla/translate - MPL 2.0 - Mozilla
// parse supported languages and model types (prod or dev)
supportedFromCodes["en"] = "prod";
supportedToCodes["en"] = "prod";
for (const [langPair, value] of Object.entries(modelRegistry)) {
const firstLang = langPair.substring(0, 2);
const secondLang = langPair.substring(2, 4);
if (firstLang !== "en") supportedFromCodes[firstLang] = value.model.modelType;
if (secondLang !== "en") supportedToCodes[secondLang] = value.model.modelType;
}
loadModel();
}
} else {
var translateCall = console.log;
}
////////
var roomID = "test";
if (urlParams.has("room")){
roomID = urlParams.get("room");
} else if (urlParams.has("ROOM")){
roomID = urlParams.get("ROOM");
} else {
// Try to use a previously stored room ID before redirecting
try {
var storedRoom = localStorage.getItem('cn_room_id') || "";
if (storedRoom && /^[A-Za-z0-9]{12,}$/.test(storedRoom)) {
roomID = storedRoom;
if (typeof updateURL === 'function') {
updateURL("room="+roomID);
}
} else {
window.location.href = "./speechin";
}
} catch (e) {
window.location.href = "./speechin";
}
}
// Persist the room ID for future sessions
try { localStorage.setItem('cn_room_id', roomID); } catch (e) {}
var clearOnNew = false;
if (urlParams.has("clear")){
clearOnNew = true;
}
var intermClear = urlParams.has("intermclear");
// Optional: limit the number of lines shown
var maxLines = null;
(function(){
let val = null;
if (urlParams.has("maxlines")) {
val = parseInt(urlParams.get("maxlines"));
} else if (urlParams.has("maxrows")) {
// Support legacy/alternate param name
val = parseInt(urlParams.get("maxrows"));
} else if (urlParams.has("rows")) {
val = parseInt(urlParams.get("rows"));
} else if (urlParams.has("lines")) {
val = parseInt(urlParams.get("lines"));
}
if (!isNaN(val) && val > 0) {
maxLines = val;
// Ensure each entry occupies only one visual line; overflow gets ellipsis
try {
var style = document.createElement('style');
style.type = 'text/css';
style.textContent = `#output, #interm { white-space: nowrap; overflow: hidden; }\n#output span, #interm span { white-space: nowrap; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }`;
document.head.appendChild(style);
} catch(e) {}
}
})();
// Option 3: Interim-aware maxlines (defaults to maxLines if not specified)
var interimMaxLines = null;
(function(){
let val = null;
if (urlParams.has("interimmaxlines")) {
val = parseInt(urlParams.get("interimmaxlines"));
} else if (urlParams.has("intermaxlines")) {
val = parseInt(urlParams.get("intermaxlines"));
}
if (!isNaN(val) && val > 0) {
interimMaxLines = val;
} else if (maxLines) {
// Default to same as maxLines
interimMaxLines = maxLines;
}
})();
// Truncate text to approximate N lines by sentence or word count
function truncateToLines(text, targetLines) {
if (!targetLines || !text) return text;
// First, try splitting on sentence boundaries
const sentencePattern = /[.!?]+\s+/g;
const sentences = text.split(sentencePattern).filter(s => s.trim());
if (sentences.length > targetLines) {
// Keep only the last N sentences
return "..." + sentences.slice(-targetLines).join('. ');
}
// If we have fewer sentences than target, try word-based limiting
// Estimate ~12 words per "line"
const wordsPerLine = 12;
const maxWordCount = targetLines * wordsPerLine;
const words = text.split(/\s+/);
if (words.length > maxWordCount) {
return "..." + words.slice(-maxWordCount).join(' ');
}
return text;
}
function adjustIntermVisibility(){
if (!maxLines) return;
const out = document.getElementById("output");
const inter = document.getElementById("interm");
if (!out || !inter) return;
let brs = out.querySelectorAll('br').length;
if (brs >= maxLines) {
inter.style.display = 'none';
} else {
inter.style.display = '';
}
}
function enforceMaxLines(){
if (!maxLines) return;
const out = document.getElementById("output");
if (!out) return;
let brs = out.querySelectorAll('br');
while (brs.length > maxLines) {
// Remove nodes from the start until and including the first <br>
while (out.firstChild && out.firstChild.nodeName.toLowerCase() !== 'br') {
out.removeChild(out.firstChild);
}
if (out.firstChild && out.firstChild.nodeName.toLowerCase() === 'br') {
out.removeChild(out.firstChild);
}
brs = out.querySelectorAll('br');
}
adjustIntermVisibility();
}
var timeoutSpeed = 5000;
if (urlParams.has("showtime")){
timeoutSpeed = parseInt(urlParams.get("showtime")) || 0;
}
var allowHTML = false;
if (urlParams.has("html")){
allowHTML = true;
}
var idle = null;
var socket;
var reconnectTimer = null;
let retryCount = 0;
const maxRetryDelay = 30000; // Maximum delay of 30 seconds
const baseDelay = 1000; // Start with 1 second delay after first immediate retry
function getRetryDelay() {
if (retryCount === 0) {
return 0; // First retry is immediate
}
// Exponential backoff: baseDelay * 2^(retryCount-1)
const delay = Math.min(baseDelay * Math.pow(2, retryCount - 1), maxRetryDelay);
return delay;
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
const delay = getRetryDelay();
console.log(`Reconnecting in ${delay}ms (attempt ${retryCount + 1})...`);
reconnectTimer = setTimeout(function() {
reconnectTimer = null;
retryCount++;
connectWebSocket();
}, delay);
}
function handleSocketMessage(event) {
if (!event.data) {
return;
}
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
return;
}
if (data.ln) {
// Update current language from socket unless translating
if (!langTo) {
currentLanguage = data.ln;
}
}
// Handle context for translation
if (langTo && useContext && data.final) {
// Store message in history
messageHistory.push(data.final);
if (messageHistory.length > contextSize + 1) {
messageHistory.shift(); // Keep only recent messages
}
// Create context text
const contextText = messageHistory.join(' ');
if (data.ln && langFrom !== data.ln.split("-")[0]) {
langFrom = data.ln.split("-")[0];
// Recalculate if translation is needed after language change
needsTranslation = langFrom.split('-')[0].toLowerCase() !== langTo.split('-')[0].toLowerCase();
if (needsTranslation && (!googleApiKey || forceLocal)) {
loadModel();
}
}
translateCall(contextText, data, data.interm||false);
} else if (langTo) {
// Original behavior without context
if (data.ln && langFrom !== data.ln.split("-")[0]) {
langFrom = data.ln.split("-")[0];
// Recalculate if translation is needed after language change
needsTranslation = langFrom.split('-')[0].toLowerCase() !== langTo.split('-')[0].toLowerCase();
if (needsTranslation && (!googleApiKey || forceLocal)) {
loadModel();
}
}
translateCall(data.final || data.interm, data, data.interm||false);
} else {
processReply(data);
}
}
var privateViewer = null, privateStarting = false;
function connectWebSocket() {
if (CaptionRelay.custom()) {
if (privateViewer || privateStarting) return;
privateStarting = true;
CaptionRelay.setup({kind: 'viewer', room: roomID}).then(function (setup) {
roomID = setup.room;
privateViewer = CaptionRelay.subscribe({room: roomID,
onCaption: function (data) { handleSocketMessage({data: JSON.stringify(data)}); },
onState: function (state) {
if (state === 'connected') { var note = document.getElementById('privateRelayStatus'); if (note) note.hidden = true; }
else CaptionRelay.showStatus('Private relay: ' + state, state === 'denied');
},
onGap: CaptionRelay.showGap
});
}).catch(function (error) { CaptionRelay.showStatus(error.message, true); });
return;
}
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
const relayJoin = CaptionRelay.join(roomID, 'read');
const ws = new WebSocket(CaptionRelay.url());
socket = ws;
ws.onmessage = function(event) {
if (socket !== ws) {
return;
}
handleSocketMessage(event);
};
ws.onclose = function(event) {
if (socket !== ws) {
return;
}
socket = null;
console.log("WebSocket connection closed. Attempting to reconnect...");
if (CaptionRelay.custom() && event.code === 1008) {
const error = document.createElement('p'); error.textContent = 'Private relay access denied. Reload with the correct viewing token.'; error.setAttribute('role', 'alert'); document.body.prepend(error); return;
}
scheduleReconnect();
};
ws.onopen = function() {
if (socket !== ws) {
return;
}
console.log("WebSocket connected. Joining room...");
// Reset retry count on successful connection
retryCount = 0;
clearReconnectTimer();
ws.send(JSON.stringify(relayJoin));
};
ws.onerror = function(error) {
if (socket !== ws) {
return;
}
console.error("WebSocket error:", error);
if (ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
};
}
connectWebSocket();
window.addEventListener('beforeunload', function() {
if (privateViewer) privateViewer.disconnect();
clearReconnectTimer();
if (socket) {
socket.onclose = null;
try {
socket.close();
} catch (e) {}
socket = null;
}
});
var lastLabel = "";
var label = ""
var sanitize = function(string) {
if (allowHTML){
return string;
}
var temp = document.createElement('div');
temp.innerHTML = string; // Assign to innerHTML to parse entities and HTML structure
let decodedAndStrippedText = temp.textContent || temp.innerText || ""; // Get plain text content; this decodes entities and strips tags
decodedAndStrippedText = decodedAndStrippedText.substring(0, Math.min(decodedAndStrippedText.length, 500));
return decodedAndStrippedText.trim();
};
function buildLineMarkup(text, originalText){
if (!text){
return "";
}
const displayText = sanitize(text);
if (dualOverlay && originalText){
const originalClean = sanitize(originalText);
if (originalClean && originalClean !== displayText){
return `${label}<span class="dual-line"><span class="dual-translation">${displayText}</span><span class="dual-original">${originalClean}</span></span><br/>`;
}
}
return `${label}<span>${displayText}</span><br/>`;
}
function processReply(data){
var newLabel = false;
if (data.label){
console.log(data.label);
try {
data.label = sanitize(data.label);
data.label = data.label.replaceAll('_', ' ');
label = "<span class='label'>"+data.label+": </span>";
} catch (e) {
return;
}
} else {
label = "";
}
// console.log(data);
if ("interm" in data){
const outputEl = document.getElementById("output");
const intermEl = document.getElementById("interm");
// Option 3: Apply interim-aware line truncation
let intermText = data.interm;
if (interimMaxLines && intermText) {
intermText = truncateToLines(intermText, interimMaxLines);
}
const intermMarkup = buildLineMarkup(intermText, data.originalInterm || data.originalFinal);
if (intermClear) {
outputEl.innerHTML = "";
enforceMaxLines();
}
intermEl.innerHTML = intermMarkup;
try { adjustIntermVisibility(); } catch(e){}
// Optional streaming TTS via tts.rocks when requested
try {
if (window.CAPTION_TTS && window.CAPTION_TTS.stream && typeof window.CAPTION_TTS.speak === 'function') {
window.CAPTION_TTS.speak(data.interm, true);
}
} catch(e){ console.warn(e); }
} else if ("final" in data){
// Decide between built-in overlay TTS and external tts.rocks
var provider = (urlParams.get('ttsprovider')||'').toLowerCase();
var forceExternal = (urlParams.get('ttslib')||'').toLowerCase() === 'rocks';
var externalPreferred = forceExternal || ['kokoro','piper','espeak','kitten','openai'].includes(provider);
try {
if (externalPreferred && window.CAPTION_TTS && typeof window.CAPTION_TTS.speak === 'function') {
window.CAPTION_TTS.speak(data.final);
} else {
// Fall back to built-in overlay TTS
tts.speak(data.final);
}
} catch(e){ console.warn(e); }
const finalMarkup = buildLineMarkup(data.final, data.originalFinal || data.originalInterm);
if (clearOnNew || data.c){
document.getElementById("output").innerHTML = finalMarkup;
} else {
document.getElementById("output").innerHTML += finalMarkup;
}
enforceMaxLines();
document.getElementById("interm").innerHTML="";
try { adjustIntermVisibility(); } catch(e){}
}
if (!clearOnNew){
if (idle){
clearInterval(idle);
}
if (timeoutSpeed){
idle = setTimeout(function(){
document.getElementById("output").innerHTML="";
document.getElementById("interm").innerHTML="";
try { adjustIntermVisibility(); } catch(e){}
},timeoutSpeed);
}
}
}
// connectWebSocket owns reconnects. Reloading here discarded private relay
// credentials held in tab memory and overrode its existing close handler.
// Optional: open a separate TTS window powered by tts.rocks bridge
try {
if (urlParams.has('ttspopout')) {
var qs = new URLSearchParams(window.location.search);
qs.set('room', roomID);
var bridgeUrl = window.location.origin + window.location.pathname.replace(/\/[^^/]*$/, '') + '/tts.rocks/caption-bridge.html?' + qs.toString();
window.open(bridgeUrl, '_blank', 'noopener');
}
} catch (e) { console.warn(e); }
</script>
<script src="overlay-style-params.js"></script>
</body>
</html>