mirror of
https://github.com/steveseguin/captionninja.git
synced 2026-10-09 17:49:30 +00:00
1289 lines
39 KiB
HTML
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="© 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>
|