mirror of
https://github.com/steveseguin/captionninja.git
synced 2026-10-09 09:39:31 +00:00
1025 lines
29 KiB
HTML
1025 lines
29 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;
|
|
}
|
|
|
|
a {
|
|
text-transform: none;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="interm" class="output"></div>
|
|
<div id="output" class="output"></div>
|
|
<div id="status" style="display:none;"></div>
|
|
<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;
|
|
|
|
// 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
|
|
|
|
// 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");
|
|
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 (!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 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;
|
|
}
|
|
};
|
|
|
|
// 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 = () => {
|
|
if (langFrom !== langTo && worker) {
|
|
status(`Installing model...`);
|
|
console.log(`Loading model '${langFrom}${langTo}'`);
|
|
worker.postMessage(["load_model", langFrom, langTo]);
|
|
} else {
|
|
console.log("No model loaded yet");
|
|
// do nothing.
|
|
}
|
|
};
|
|
|
|
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 {
|
|
window.location.href = "./speechin";
|
|
}
|
|
|
|
var clearOnNew = false;
|
|
if (urlParams.has("clear")){
|
|
clearOnNew = true;
|
|
}
|
|
|
|
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];
|
|
if (!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];
|
|
if (!googleApiKey || forceLocal) {
|
|
loadModel();
|
|
}
|
|
}
|
|
translateCall(data.final || data.interm, data, data.interm||false);
|
|
} else {
|
|
processReply(data);
|
|
}
|
|
}
|
|
|
|
function connectWebSocket() {
|
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
const ws = new WebSocket("wss://api.caption.ninja:443");
|
|
socket = ws;
|
|
ws.onmessage = function(event) {
|
|
if (socket !== ws) {
|
|
return;
|
|
}
|
|
handleSocketMessage(event);
|
|
};
|
|
|
|
ws.onclose = function() {
|
|
if (socket !== ws) {
|
|
return;
|
|
}
|
|
socket = null;
|
|
console.log("WebSocket connection closed. Attempting to reconnect...");
|
|
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({"join":roomID}));
|
|
};
|
|
|
|
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() {
|
|
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 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){
|
|
document.getElementById("interm").innerHTML = label+"<span>"+sanitize(data.interm)+"</span><br/>";
|
|
} else if ("final" in data){
|
|
|
|
tts.speak(data.final);
|
|
|
|
if (clearOnNew || data.c){
|
|
document.getElementById("output").innerHTML = label+"<span>"+sanitize(data.final)+"</span><br/>";
|
|
} else {
|
|
document.getElementById("output").innerHTML += label+"<span>"+sanitize(data.final)+"</span><br/>";
|
|
}
|
|
document.getElementById("interm").innerHTML="";
|
|
}
|
|
|
|
if (!clearOnNew){
|
|
if (idle){
|
|
clearInterval(idle);
|
|
}
|
|
if (timeoutSpeed){
|
|
idle = setTimeout(function(){
|
|
document.getElementById("output").innerHTML="";
|
|
document.getElementById("interm").innerHTML="";
|
|
},timeoutSpeed);
|
|
}
|
|
}
|
|
}
|
|
|
|
socket.onclose = function (){
|
|
setTimeout(function(){window.location.reload(true);},100);
|
|
};
|
|
|
|
</script>
|
|
<script src="overlay-style-params.js"></script>
|
|
</body>
|
|
</html>
|