mirror of
https://github.com/steveseguin/captionninja.git
synced 2026-10-09 09:39:31 +00:00
2020 lines
69 KiB
HTML
2020 lines
69 KiB
HTML
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
|
|
<!-- Primary Meta Tags -->
|
|
<title>CAPTION.Ninja - Free Real-Time Translation & Captions</title>
|
|
<meta name="title" content="CAPTION.Ninja - Free Real-Time Translation & Captions" />
|
|
<meta name="description" content="Free real-time speech translation for live streams. Translate captions into 17+ languages using local AI (Mozilla Bergamot) or 100+ languages with Google Cloud. Perfect for multilingual broadcasts." />
|
|
<meta name="keywords" content="translation, real-time translation, live translation, captions, subtitles, multilingual, OBS, streaming, speech to text, Mozilla Bergamot" />
|
|
<meta name="author" content="Steve Seguin" />
|
|
<meta name="robots" content="index, follow" />
|
|
<link rel="canonical" href="https://caption.ninja/translate" />
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:url" content="https://caption.ninja/translate" />
|
|
<meta property="og:title" content="CAPTION.Ninja - Free Real-Time Translation" />
|
|
<meta property="og:description" content="Free real-time speech translation for live streams. Translate into 17+ languages locally or 100+ with Google Cloud." />
|
|
<meta property="og:image" content="https://caption.ninja/favicon.svg" />
|
|
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary" />
|
|
<meta name="twitter:title" content="CAPTION.Ninja - Free Real-Time Translation" />
|
|
<meta name="twitter:description" content="Free real-time speech translation for live streams." />
|
|
<meta name="twitter:creator" content="@steveseguin" />
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
<link rel="shortcut icon" href="favicon.svg" />
|
|
|
|
|
|
<style>
|
|
@font-face {
|
|
font-family: 'Cousine';
|
|
src: url('fonts/Cousine-Bold.ttf') format('truetype');
|
|
}
|
|
|
|
:root {
|
|
--primary-color: #2498Eb;
|
|
--secondary-color: #2c3e50;
|
|
--background-color: #0000;
|
|
--text-color: #333;
|
|
--border-radius: 4px;
|
|
--cc-background: #000000;
|
|
--cc-text-color: #ffffff;
|
|
--cc-font-size: 24px;
|
|
--cc-line-height: 1.2;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Arial', sans-serif;
|
|
line-height: 1.6;
|
|
color: var(--text-color);
|
|
background-color: var(--background-color);
|
|
margin: 0;
|
|
padding: 20px;
|
|
}
|
|
|
|
h3 {
|
|
color: var(--secondary-color);
|
|
border-bottom: 2px solid var(--primary-color);
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
a {
|
|
color: var(--primary-color);
|
|
text-decoration: none;
|
|
font-weight:600;
|
|
transition: color 0.3s ease;
|
|
}
|
|
|
|
a:hover {
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.output {
|
|
font-family: Cousine, monospace;
|
|
margin-top:15px;
|
|
color: black;
|
|
min-height: 120px;
|
|
font-size: 3.2em;
|
|
line-height: 1.1em;
|
|
letter-spacing: 0.0em;
|
|
padding: 0em;
|
|
padding-top:10px;
|
|
text-shadow: 0.05em 0.05em 0px rgba(255,255,255,1);
|
|
}
|
|
|
|
.output span {
|
|
background-color: black;
|
|
padding: 8px 8px 0px 8px;
|
|
margin:0;
|
|
}
|
|
|
|
/* Add this new style for the caption container */
|
|
#caption-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 200px; /* Adjust as needed */
|
|
overflow: hidden;
|
|
background-color: var(--cc-background);
|
|
}
|
|
|
|
select, input[type="checkbox"] {
|
|
margin: 10px 0;
|
|
}
|
|
|
|
select {
|
|
padding: 5px;
|
|
border-radius: var(--border-radius);
|
|
border: 1px solid #ddd;
|
|
}
|
|
|
|
label {
|
|
display: inline-block;
|
|
margin-right: 15px;
|
|
}
|
|
|
|
textarea {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
border-radius: var(--border-radius);
|
|
resize: vertical;
|
|
}
|
|
|
|
#status {
|
|
margin-top: 10px;
|
|
font-style: italic;
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
#whosekey {
|
|
display: inline-block;
|
|
padding: 5px 10px;
|
|
border-radius: var(--border-radius);
|
|
font-weight: bold;
|
|
}
|
|
|
|
#whosekey[style*="yellow"] {
|
|
background-color: #ffeeba;
|
|
color: #856404;
|
|
}
|
|
|
|
#whosekey[style*="green"] {
|
|
background-color: #d4edda;
|
|
color: #155724;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
body {
|
|
padding: 10px;
|
|
}
|
|
|
|
textarea {
|
|
width: calc(100% - 20px);
|
|
}
|
|
|
|
.output span {
|
|
font-size: calc(var(--cc-font-size) * 0.8);
|
|
}
|
|
}
|
|
|
|
.github {
|
|
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAAXNSR0IArs4c6QAAAd5JREFUOE+d1MurjlEUBvDfIZQZShEGmMhQiZGZlAlyK4eEAZHcyiVRyq1cUq5FwsD1MJGJiT/AzEzRcR8xMnGOW89pv9q9fScfe/J977o8e61nP2v16HzGYBWWYA4mlbBPeI5HuI9v7fSeDnircRqTh7msMX/ALtyr42rAEbiAzX8BaruTsx0/46gBL/0HWAMe0G01YNq8Xbz5PYuN2ISvCHc5oWEsruA69mJF8a0Mr6kwD/C64mw3zpSgCfjc6nE8vhRbAE+U/+8xI4DrcKNKOol9XfJ4CimgOb0BfIilxfIds/GyS8BZeIGRJf5BAN9gWjE8xcIuwZqwZ1hQPvoDOIBRxRCiN/wj4C30lpyBNmAmYNk/Aj7G4pIz2G75I6Y2Iu0CONwlZ2Ldcl+rqmjvWhdgCdmCi1Xs0KOsxc3C5WDhcw8uI9+dzugyVZFNw3/i1jTCfoVIJmo/VDiJeJ9gPX4U1CRHs4swrnXTO8xsZjlAd9GP+WU1zcPxckGde7WMZbvy5eirl8N5bMUxHMSvYdo9igMt3znsiK29vuLI1ojYU+2dwmWdnwv3V4YskozfUAGdFmzaz4KdgiM43KomNGTW32JnGd0/IZ0A48wrhpPMdFZ+feZievjqpILf7lRg3csIRqAAAAAASUVORK5CYII=");
|
|
background-color: #FFF !important;
|
|
width: 4px;
|
|
height: 12px;
|
|
background-repeat: no-repeat;
|
|
display: inline-block;
|
|
top: 2px;
|
|
position: relative;
|
|
left: 2px;
|
|
filter: invert(100%);
|
|
-webkit-filter: invert(100%);
|
|
}
|
|
.toggle-button {
|
|
background-color: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: var(--border-radius);
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
transition: background-color 0.3s ease;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.toggle-button:hover {
|
|
background-color: var(--secondary-color);
|
|
}
|
|
|
|
.toggle-button.paused {
|
|
background-color: #dc3545;
|
|
}
|
|
|
|
#tts-options {
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
#tts-options select,
|
|
#tts-options input {
|
|
padding: 4px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
#tts-options label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
margin-right: 10px;
|
|
}
|
|
|
|
.tts-provider-options {
|
|
margin-top: 10px;
|
|
padding: 10px;
|
|
border-top: 1px solid #ddd;
|
|
}
|
|
|
|
.input-group {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.input-group input[type="text"],
|
|
.input-group input[type="password"] {
|
|
width: 300px;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h3>This is a free-to-use captioning, transcription, and translation tool.</h3>
|
|
The overlay-friendly output of the text is mirrored here: 👉 <a id="shareLink" href="overlay.html" target='_blank'>*ERROR GENERATING LINK*</a>.
|
|
<br /><br />
|
|
To use this app, accept the microphone permissions on page load and then just say something outloud.
|
|
<br /><br />
|
|
Please note that this app uses your default microphone as the audio input source. You sometimes can change the default audio source via the browser's setting, but you can also change it at your system level by changing the default recording device. You can also change audio sources by using a Virtual Audio Cable, <a href='https://www.vb-audio.com/Cable/'> such as this one.</a> Using it, it becomes possible to select other sources, including microphones, speakers, and other applications.
|
|
<br /><br />
|
|
The translation component is based on code from Mozilla <a href="https://github.com/mozilla/translate">https://github.com/mozilla/translate</a>. If your language is missing though, or you want better results, I have a version <a href='https://caption.ninja/translate_premium''>powered by Google Cloud Translation here</a>. Check out more options and details on <a href='https://github.com/steveseguin/captionninja'>GitHub</a>.
|
|
<br /><br />
|
|
<label>
|
|
From
|
|
<select id="lang-from" title="Set input language via the URL `&lang=en-US` option" name="from" class="lang-select"></select>
|
|
</label>
|
|
|
|
<label>
|
|
To
|
|
<select id="lang-to" name="to" class="lang-select"></select>
|
|
</label>
|
|
|
|
<label title="Enabling added context with TTS will cause double speak">
|
|
Translate with added context?
|
|
<input type="checkbox" id="fullContext" />
|
|
</label>
|
|
|
|
<label>
|
|
Enable Text to Speech in overlay?
|
|
<input type="checkbox" id="enableTTS" />
|
|
</label>
|
|
|
|
<label title="Reload the page using the previous speech recognition handling">
|
|
Use legacy speech mode?
|
|
<input type="checkbox" id="legacySpeechMode" />
|
|
</label>
|
|
|
|
<button id="toggleTranscription" class="toggle-button">Pause Transcription</button>
|
|
<br />
|
|
|
|
<!-- Interim Text Handling Options -->
|
|
<div id="interim-options" style="margin: 15px 0; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
|
<strong style="display: block; margin-bottom: 8px;">Interim Text Handling (for fast speech/talk shows):</strong>
|
|
<label style="margin-right: 15px;" title="Force finalization after silence (ms). Recommended: 800">
|
|
Auto-finalize (ms):
|
|
<input type="number" id="autoFinalInput" min="0" value="0" style="width: 70px;" placeholder="0=off" />
|
|
</label>
|
|
<label style="margin-right: 15px;" title="Auto-split on sentence boundaries (. ! ?)">
|
|
Auto-split sentences
|
|
<input type="checkbox" id="autoSplitInput" />
|
|
</label>
|
|
<label style="margin-right: 15px;" title="Limit interim text to N words (0=unlimited)">
|
|
Max words:
|
|
<input type="number" id="maxWordsInput" min="0" value="0" style="width: 60px;" />
|
|
</label>
|
|
<label title="Limit interim text to N characters (0=unlimited)">
|
|
Max chars:
|
|
<input type="number" id="maxCharsInput" min="0" value="0" style="width: 60px;" />
|
|
</label>
|
|
</div>
|
|
|
|
<div id="tts-controls" style="margin-top: 10px;">
|
|
<div id="tts-options" style="display: none; margin: 10px 0;">
|
|
<div class="tts-provider-select" style="margin-bottom: 10px;">
|
|
<select id="tts-provider">
|
|
<option value="system">System TTS</option>
|
|
<option value="elevenlabs">ElevenLabs</option>
|
|
<option value="google">Google Cloud</option>
|
|
<option value="speechify">Speechify</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- System TTS Options -->
|
|
<div class="tts-provider-options" id="system-options">
|
|
<select id="tts-voice" style="margin-right: 10px;">
|
|
<option value="">System Default Voice</option>
|
|
</select>
|
|
|
|
<label style="margin-right: 10px;">
|
|
Rate:
|
|
<input type="number" id="tts-rate" min="0.1" max="10" step="0.1" value="1.0" style="width: 60px;" />
|
|
</label>
|
|
|
|
<label style="margin-right: 10px;">
|
|
Pitch:
|
|
<input type="number" id="tts-pitch" min="0" max="2" step="0.1" value="1.0" style="width: 60px;" />
|
|
</label>
|
|
|
|
<label>
|
|
Volume:
|
|
<input type="number" id="tts-volume" min="0" max="1" step="0.1" value="1.0" style="width: 60px;" />
|
|
</label>
|
|
</div>
|
|
|
|
<!-- ElevenLabs Options -->
|
|
<div class="tts-provider-options" id="elevenlabs-options" style="display: none;">
|
|
<div class="input-group">
|
|
<label>
|
|
API Key:
|
|
<input type="password" id="elevenlabs-key" placeholder="Your ElevenLabs API Key" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Voice ID:
|
|
<input type="text" id="elevenlabs-voice" placeholder="e.g., VR6AewLTigWG4xSOukaG" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Model:
|
|
<select id="elevenlabs-model">
|
|
<option value="eleven_multilingual_v2">eleven_multilingual_v2</option>
|
|
<option value="eleven_turbo_v2">eleven_turbo_v2</option>
|
|
<option value="eleven_english_sts_v2">eleven_english_sts_v2</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Speaking Rate:
|
|
<input type="number" id="elevenlabs-rate" min="0.25" max="5.0" step="0.25" value="1.0" />
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<input type="checkbox" id="elevenlabs-speaker-boost" />
|
|
Use Speaker Boost
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Google Cloud Options -->
|
|
<div class="tts-provider-options" id="google-options" style="display: none;">
|
|
<div class="input-group">
|
|
<label>
|
|
API Key:
|
|
<input type="password" id="google-key" placeholder="Your Google Cloud API Key" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Voice Name:
|
|
<input type="text" id="google-voice" placeholder="e.g., en-GB-Standard-A" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Rate:
|
|
<input type="number" id="google-rate" min="0.25" max="4" step="0.1" value="1.0" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Pitch:
|
|
<input type="number" id="google-pitch" min="-20" max="20" step="1" value="0" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Audio Profile:
|
|
<select id="google-profile">
|
|
<option value="handset-class-device">Handset</option>
|
|
<option value="headphone-class-device">Headphone</option>
|
|
<option value="small-bluetooth-speaker-class-device">Small Speaker</option>
|
|
<option value="medium-bluetooth-speaker-class-device">Medium Speaker</option>
|
|
<option value="large-home-entertainment-class-device">Large Speaker</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Speechify Options -->
|
|
<div class="tts-provider-options" id="speechify-options" style="display: none;">
|
|
<div class="input-group">
|
|
<label>
|
|
API Key:
|
|
<input type="password" id="speechify-key" placeholder="Your Speechify API Key" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Voice ID:
|
|
<input type="text" id="speechify-voice" placeholder="e.g., henry" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Speed:
|
|
<input type="number" id="speechify-speed" min="0.1" max="3.0" step="0.1" value="1.0" />
|
|
</label>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>
|
|
Model:
|
|
<select id="speechify-model">
|
|
<option value="simba-english">English</option>
|
|
<option value="simba-multilingual">Multilingual</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<br />
|
|
<div class="footer" id="status"></div>
|
|
<textarea id="input" name="input"></textarea>
|
|
<br />
|
|
<div id="output" class="output"></div>
|
|
|
|
|
|
<script src="security-utils.js"></script>
|
|
<script src="ws-publisher.js"></script>
|
|
<script src="text-rules.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);
|
|
var textRulesConfig = (window.CaptionTextRules && window.CaptionTextRules.parseRulesFromUrlParams)
|
|
? window.CaptionTextRules.parseRulesFromUrlParams(urlParams)
|
|
: { replacements: [], restricted: [], matchMode: 'phonetic' };
|
|
var textRulesEngine = (window.CaptionTextRules && window.CaptionTextRules.createEngine)
|
|
? window.CaptionTextRules.createEngine(textRulesConfig)
|
|
: null;
|
|
|
|
function applyTextRules(text) {
|
|
if (!textRulesEngine || typeof text !== 'string') return text;
|
|
try {
|
|
return textRulesEngine.apply(text);
|
|
} catch (e) {
|
|
console.warn("Text rules failed:", e);
|
|
return text;
|
|
}
|
|
}
|
|
let currentTranslationId = 0;
|
|
let worker;
|
|
let modelRegistry;
|
|
let version;
|
|
const status = function(message) {
|
|
document.getElementById("status").innerText = message;
|
|
}
|
|
const langs = {
|
|
"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"
|
|
};
|
|
let isPaused = false;
|
|
const toggleButton = document.getElementById('toggleTranscription');
|
|
|
|
toggleButton.addEventListener('click', () => {
|
|
isPaused = !isPaused;
|
|
|
|
if (isPaused) {
|
|
if (recognitionRecoveryTimer) {
|
|
clearTimeout(recognitionRecoveryTimer);
|
|
recognitionRecoveryTimer = null;
|
|
}
|
|
clearPendingInterimTranslation();
|
|
recognition.stop();
|
|
toggleButton.textContent = 'Resume Transcription';
|
|
toggleButton.classList.add('paused');
|
|
} else {
|
|
recognition.start();
|
|
toggleButton.textContent = 'Pause Transcription';
|
|
toggleButton.classList.remove('paused');
|
|
}
|
|
});
|
|
|
|
var myLang = navigator.language || "en-US";
|
|
if (urlParams.has("lang")) {
|
|
myLang = urlParams.get("lang");
|
|
} else {
|
|
updateURL("lang=" + myLang);
|
|
}
|
|
|
|
var myLangCode = myLang.split("-")[0].toLowerCase();
|
|
var targetCode = "de";
|
|
// Speech batching defaults to the improved mode. Use &speechmode=legacy
|
|
// (or &speechmode=v1) to fall back to the previous recognition handling.
|
|
const speechMode = (urlParams.get("speechmode") || "").toLowerCase();
|
|
const useLegacySpeechMode = speechMode === "legacy" || speechMode === "v1";
|
|
const useSpeechModeV2 = !useLegacySpeechMode;
|
|
if (myLangCode == "de") {
|
|
targetCode = "en";
|
|
}
|
|
if (urlParams.has("translate") || urlParams.has("target")) {
|
|
targetCode = urlParams.get("translate") || urlParams.get("target") || targetCode;
|
|
targetCode = targetCode.split("-")[0].toLowerCase();
|
|
} else {
|
|
updateURL("translate=" + targetCode);
|
|
}
|
|
|
|
console.log("Language: " + myLang);
|
|
const langFrom = document.getElementById("lang-from");
|
|
const langTo = document.getElementById("lang-to");
|
|
|
|
let supportedFromCodes = {};
|
|
let supportedToCodes = {};
|
|
let currentTo = null;
|
|
let translationQueue = [];
|
|
let isTranslating = false;
|
|
let translationCounter = 0;
|
|
let pendingTranslations = new Map();
|
|
let currentModelPair = "";
|
|
let modelReady = false;
|
|
const interimTranslationMinIntervalMs = useSpeechModeV2 ? 400 : 0;
|
|
const interimBacklogHoldMs = useSpeechModeV2 ? 1200 : 0;
|
|
const interimStableTailWords = 6;
|
|
const interimRewriteConfirmations = 2;
|
|
const maxInterimStableTailWords = 8;
|
|
const rollingInterimFinalizeLeadWords = 6;
|
|
let lastInterimTranslationAt = 0;
|
|
let pendingInterimTranslationText = "";
|
|
let pendingInterimTranslationTimer = null;
|
|
let interimBacklogUntil = 0;
|
|
let lastDisplayedInterimWords = [];
|
|
let pendingInterimRewrite = null;
|
|
let lastInterimInputWords = [];
|
|
let lastInterimInputAt = 0;
|
|
let recentInterimWordsPerSecond = 0;
|
|
|
|
|
|
if (window.Worker) {
|
|
worker = new Worker("worker.js?v=2");
|
|
worker.postMessage(["import"]);
|
|
}
|
|
|
|
function translateCall(text, interm = false) {
|
|
if (!text.trim().length) return;
|
|
|
|
const translationId = ++translationCounter;
|
|
const paragraphs = text.split("\n");
|
|
const lngFrom = langFrom.value;
|
|
const lngTo = langTo.value;
|
|
const requestedPair = `${lngFrom}${lngTo}`;
|
|
|
|
if (!modelReady || currentModelPair !== requestedPair) {
|
|
if (interm) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Add to queue with priority (final translations get higher priority)
|
|
translationQueue.push({
|
|
id: translationId,
|
|
from: lngFrom,
|
|
to: lngTo,
|
|
text: paragraphs,
|
|
interm: interm,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Store in pending translations map
|
|
pendingTranslations.set(translationId, {
|
|
text: text,
|
|
interm: interm
|
|
});
|
|
|
|
// Process queue if not already processing
|
|
if (modelReady && !isTranslating) {
|
|
processTranslationQueue();
|
|
}
|
|
}
|
|
|
|
function hasActiveInterimTranslation() {
|
|
if (isTranslating) {
|
|
const activeTranslation = pendingTranslations.get(currentTranslationId);
|
|
if (activeTranslation && activeTranslation.interm) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return translationQueue.some(item => item.interm);
|
|
}
|
|
|
|
function clearPendingInterimTranslation() {
|
|
pendingInterimTranslationText = "";
|
|
interimBacklogUntil = 0;
|
|
if (pendingInterimTranslationTimer) {
|
|
clearTimeout(pendingInterimTranslationTimer);
|
|
pendingInterimTranslationTimer = null;
|
|
}
|
|
}
|
|
|
|
function enterInterimBacklogMode() {
|
|
if (!useSpeechModeV2 || !interimBacklogHoldMs) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const wasBacklogged = interimBacklogUntil > now;
|
|
interimBacklogUntil = Math.max(interimBacklogUntil, now + interimBacklogHoldMs);
|
|
if (!wasBacklogged) {
|
|
console.log("Holding interim translation until backlog clears");
|
|
}
|
|
}
|
|
|
|
function getInterimBacklogDelay(now = Date.now()) {
|
|
return Math.max(0, interimBacklogUntil - now);
|
|
}
|
|
|
|
function resetInterimOutputStabilizer() {
|
|
lastDisplayedInterimWords = [];
|
|
pendingInterimRewrite = null;
|
|
lastInterimInputWords = [];
|
|
lastInterimInputAt = 0;
|
|
recentInterimWordsPerSecond = 0;
|
|
}
|
|
|
|
function tokenizeCaptionWords(text) {
|
|
if (!text) return [];
|
|
return text.trim().split(/\s+/).filter(Boolean);
|
|
}
|
|
|
|
function countSharedPrefixWords(previousWords, nextWords) {
|
|
const minLength = Math.min(previousWords.length, nextWords.length);
|
|
let shared = 0;
|
|
|
|
for (let i = 0; i < minLength; i += 1) {
|
|
if (previousWords[i] !== nextWords[i]) {
|
|
break;
|
|
}
|
|
shared += 1;
|
|
}
|
|
|
|
return shared;
|
|
}
|
|
|
|
function trackInterimInputRate(text) {
|
|
if (!useSpeechModeV2) {
|
|
return;
|
|
}
|
|
|
|
const nextWords = tokenizeCaptionWords(text);
|
|
const now = Date.now();
|
|
if (lastInterimInputAt && nextWords.length >= lastInterimInputWords.length) {
|
|
const addedWords = nextWords.length - lastInterimInputWords.length;
|
|
const elapsedMs = now - lastInterimInputAt;
|
|
if (addedWords > 0 && elapsedMs > 0) {
|
|
const instantRate = addedWords / (elapsedMs / 1000);
|
|
recentInterimWordsPerSecond = recentInterimWordsPerSecond
|
|
? (recentInterimWordsPerSecond * 0.7) + (instantRate * 0.3)
|
|
: instantRate;
|
|
}
|
|
} else if (nextWords.length < lastInterimInputWords.length) {
|
|
recentInterimWordsPerSecond = 0;
|
|
}
|
|
|
|
lastInterimInputWords = nextWords;
|
|
lastInterimInputAt = now;
|
|
}
|
|
|
|
function getDynamicInterimTailWords(totalWords) {
|
|
let tailWords = interimStableTailWords;
|
|
|
|
if (recentInterimWordsPerSecond > 4) tailWords += 1;
|
|
if (totalWords > 24) tailWords += 1;
|
|
|
|
return Math.min(maxInterimStableTailWords, Math.max(interimStableTailWords, tailWords));
|
|
}
|
|
|
|
function getRequiredInterimRewriteConfirmations(stablePrefixLength, sharedPrefixLength, tailWords) {
|
|
const rewriteDepth = Math.max(0, stablePrefixLength - sharedPrefixLength);
|
|
let requiredConfirmations = interimRewriteConfirmations + Math.ceil(rewriteDepth / Math.max(4, tailWords));
|
|
|
|
if (recentInterimWordsPerSecond > 4) {
|
|
requiredConfirmations += 1;
|
|
}
|
|
|
|
return Math.min(6, Math.max(interimRewriteConfirmations, requiredConfirmations));
|
|
}
|
|
|
|
function maybePromoteInterimPrefix(text, finalizeChunk) {
|
|
if (!useSpeechModeV2) {
|
|
return text;
|
|
}
|
|
|
|
let remainingText = text;
|
|
let remainingWords = tokenizeCaptionWords(remainingText);
|
|
|
|
while (remainingWords.length > 0) {
|
|
const tailWords = getDynamicInterimTailWords(remainingWords.length);
|
|
const triggerWords = tailWords + rollingInterimFinalizeLeadWords;
|
|
if (remainingWords.length <= triggerWords) {
|
|
break;
|
|
}
|
|
|
|
const finalizedWordCount = remainingWords.length - tailWords;
|
|
const finalizedChunk = remainingWords.slice(0, finalizedWordCount).join(' ');
|
|
if (!finalizedChunk) {
|
|
break;
|
|
}
|
|
|
|
finalizeChunk(finalizedChunk);
|
|
remainingWords = remainingWords.slice(finalizedWordCount);
|
|
remainingText = remainingWords.join(' ');
|
|
}
|
|
|
|
return remainingText;
|
|
}
|
|
|
|
function stabilizeInterimOutput(text) {
|
|
if (!useSpeechModeV2) {
|
|
return text;
|
|
}
|
|
|
|
const nextWords = tokenizeCaptionWords(text);
|
|
if (!nextWords.length) {
|
|
resetInterimOutputStabilizer();
|
|
return text;
|
|
}
|
|
|
|
const dynamicTailWords = getDynamicInterimTailWords(nextWords.length);
|
|
if (!lastDisplayedInterimWords.length || nextWords.length <= dynamicTailWords) {
|
|
lastDisplayedInterimWords = nextWords;
|
|
pendingInterimRewrite = null;
|
|
return nextWords.join(' ');
|
|
}
|
|
|
|
const stablePrefixLength = Math.max(0, lastDisplayedInterimWords.length - dynamicTailWords);
|
|
const sharedPrefixLength = countSharedPrefixWords(lastDisplayedInterimWords, nextWords);
|
|
|
|
if (sharedPrefixLength >= stablePrefixLength) {
|
|
lastDisplayedInterimWords = nextWords;
|
|
pendingInterimRewrite = null;
|
|
return nextWords.join(' ');
|
|
}
|
|
|
|
const rewriteAnchor = nextWords.slice(sharedPrefixLength, Math.min(sharedPrefixLength + 3, nextWords.length)).join("\u0001");
|
|
const rewriteKey = `${sharedPrefixLength}:${rewriteAnchor}`;
|
|
const requiredConfirmations = getRequiredInterimRewriteConfirmations(stablePrefixLength, sharedPrefixLength, dynamicTailWords);
|
|
if (pendingInterimRewrite && pendingInterimRewrite.key === rewriteKey) {
|
|
pendingInterimRewrite.count += 1;
|
|
pendingInterimRewrite.words = nextWords;
|
|
pendingInterimRewrite.requiredConfirmations = Math.max(pendingInterimRewrite.requiredConfirmations, requiredConfirmations);
|
|
} else {
|
|
pendingInterimRewrite = {
|
|
key: rewriteKey,
|
|
count: 1,
|
|
words: nextWords,
|
|
requiredConfirmations: requiredConfirmations
|
|
};
|
|
}
|
|
|
|
if (pendingInterimRewrite.count >= pendingInterimRewrite.requiredConfirmations) {
|
|
lastDisplayedInterimWords = pendingInterimRewrite.words;
|
|
pendingInterimRewrite = null;
|
|
return lastDisplayedInterimWords.join(' ');
|
|
}
|
|
|
|
const preservedPrefixLength = Math.min(stablePrefixLength, lastDisplayedInterimWords.length);
|
|
const stabilizedWords = lastDisplayedInterimWords
|
|
.slice(0, preservedPrefixLength)
|
|
.concat(nextWords.slice(Math.min(preservedPrefixLength, nextWords.length)));
|
|
|
|
lastDisplayedInterimWords = stabilizedWords;
|
|
return stabilizedWords.join(' ');
|
|
}
|
|
|
|
function flushPendingInterimTranslation() {
|
|
pendingInterimTranslationTimer = null;
|
|
if (!useSpeechModeV2 || !pendingInterimTranslationText || !modelReady) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const backlogDelay = getInterimBacklogDelay(now);
|
|
if (backlogDelay > 0) {
|
|
schedulePendingInterimTranslation(backlogDelay);
|
|
return;
|
|
}
|
|
|
|
const timeUntilNextSlot = interimTranslationMinIntervalMs - (now - lastInterimTranslationAt);
|
|
if (timeUntilNextSlot > 0) {
|
|
schedulePendingInterimTranslation(timeUntilNextSlot);
|
|
return;
|
|
}
|
|
|
|
if (hasActiveInterimTranslation()) {
|
|
schedulePendingInterimTranslation(interimTranslationMinIntervalMs);
|
|
return;
|
|
}
|
|
|
|
const textToTranslate = pendingInterimTranslationText;
|
|
pendingInterimTranslationText = "";
|
|
lastInterimTranslationAt = Date.now();
|
|
translateCall(textToTranslate, true);
|
|
}
|
|
|
|
function schedulePendingInterimTranslation(delayMs = interimTranslationMinIntervalMs) {
|
|
if (!useSpeechModeV2 || !pendingInterimTranslationText) {
|
|
return;
|
|
}
|
|
|
|
if (pendingInterimTranslationTimer) {
|
|
clearTimeout(pendingInterimTranslationTimer);
|
|
}
|
|
|
|
pendingInterimTranslationTimer = setTimeout(flushPendingInterimTranslation, Math.max(delayMs, 0));
|
|
}
|
|
|
|
function enqueueInterimTranslation(text) {
|
|
if (!text.trim().length) return;
|
|
|
|
if (!useSpeechModeV2) {
|
|
translateCall(text, true);
|
|
return;
|
|
}
|
|
|
|
pendingInterimTranslationText = text;
|
|
const now = Date.now();
|
|
const backlogDelay = getInterimBacklogDelay(now);
|
|
const timeUntilNextSlot = interimTranslationMinIntervalMs - (now - lastInterimTranslationAt);
|
|
|
|
if (backlogDelay > 0 || timeUntilNextSlot > 0 || hasActiveInterimTranslation()) {
|
|
schedulePendingInterimTranslation(Math.max(backlogDelay, timeUntilNextSlot, 0));
|
|
return;
|
|
}
|
|
|
|
flushPendingInterimTranslation();
|
|
}
|
|
|
|
var label = false;
|
|
if (urlParams.has("label")) {
|
|
label = urlParams.get("label");
|
|
}
|
|
var counter = 0;
|
|
|
|
worker.onmessage = function(e) {
|
|
if (e.data[0] === "translate_reply" && e.data[1]) {
|
|
// Get the pending translation info
|
|
const pendingTranslation = pendingTranslations.get(currentTranslationId);
|
|
if (pendingTranslation) {
|
|
const isInterimReply = pendingTranslation.interm || e.data[3];
|
|
const translatedJoined = e.data[1].join("\n\n");
|
|
const outputText = isInterimReply ? stabilizeInterimOutput(translatedJoined) : translatedJoined;
|
|
document.getElementById("output").innerText = outputText;
|
|
|
|
if (isInterimReply) {
|
|
if (label) {
|
|
wsPublisher.publish({
|
|
"msg": true,
|
|
"interm": outputText,
|
|
"id": currentTranslationId,
|
|
"label": label,
|
|
"c": document.getElementById("fullContext").checked,
|
|
"ln": langTo.value
|
|
});
|
|
} else {
|
|
wsPublisher.publish({
|
|
"msg": true,
|
|
"interm": outputText,
|
|
"id": currentTranslationId,
|
|
"c": document.getElementById("fullContext").checked,
|
|
"ln": langTo.value
|
|
});
|
|
}
|
|
pendingTranslations.delete(currentTranslationId);
|
|
} else {
|
|
resetInterimOutputStabilizer();
|
|
if (label) {
|
|
wsPublisher.publish({
|
|
"msg": true,
|
|
"final": outputText,
|
|
"id": currentTranslationId,
|
|
"label": label,
|
|
"c": document.getElementById("fullContext").checked,
|
|
"ln": langTo.value
|
|
});
|
|
} else {
|
|
wsPublisher.publish({
|
|
"msg": true,
|
|
"final": outputText,
|
|
"id": currentTranslationId,
|
|
"c": document.getElementById("fullContext").checked,
|
|
"ln": langTo.value
|
|
});
|
|
}
|
|
|
|
// Clean up old pending translations
|
|
pendingTranslations.forEach((value, key) => {
|
|
if (key <= currentTranslationId) {
|
|
pendingTranslations.delete(key);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Continue processing queue
|
|
setTimeout(processTranslationQueue, 0);
|
|
if (useSpeechModeV2 && pendingInterimTranslationText) {
|
|
schedulePendingInterimTranslation(0);
|
|
}
|
|
} else if (e.data[0] === "load_model_reply" && e.data[1]) {
|
|
status(e.data[1]);
|
|
modelReady = /^Model successfully loaded/.test(e.data[1]);
|
|
isTranslating = false;
|
|
if (modelReady && translationQueue.length) {
|
|
processTranslationQueue();
|
|
}
|
|
if (modelReady && useSpeechModeV2 && pendingInterimTranslationText) {
|
|
schedulePendingInterimTranslation(0);
|
|
}
|
|
} else if (e.data[0] === "import_reply" && e.data[1]) {
|
|
modelRegistry = e.data[1];
|
|
version = e.data[2];
|
|
init();
|
|
isTranslating = false;
|
|
}
|
|
};
|
|
|
|
function processTranslationQueue() {
|
|
if (!modelReady) {
|
|
isTranslating = false;
|
|
return;
|
|
}
|
|
|
|
if (translationQueue.length === 0) {
|
|
isTranslating = false;
|
|
if (useSpeechModeV2 && pendingInterimTranslationText) {
|
|
schedulePendingInterimTranslation(0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
isTranslating = true;
|
|
|
|
// Sort queue: final translations first, then most recent interim translations
|
|
translationQueue.sort((a, b) => {
|
|
// Final translations get priority over interim
|
|
if (!a.interm && b.interm) return -1;
|
|
if (a.interm && !b.interm) return 1;
|
|
|
|
// For interim translations, prioritize newest
|
|
if (a.interm && b.interm) {
|
|
return b.timestamp - a.timestamp;
|
|
}
|
|
|
|
// For final translations, prioritize oldest (in order)
|
|
return a.timestamp - b.timestamp;
|
|
});
|
|
|
|
// Remove outdated interim translations, keeping only the most recent one
|
|
let mostRecentInterim = null;
|
|
const newQueue = [];
|
|
|
|
for (const item of translationQueue) {
|
|
if (item.interm) {
|
|
if (!mostRecentInterim || item.timestamp > mostRecentInterim.timestamp) {
|
|
if (mostRecentInterim) {
|
|
pendingTranslations.delete(mostRecentInterim.id);
|
|
}
|
|
mostRecentInterim = item;
|
|
} else {
|
|
pendingTranslations.delete(item.id);
|
|
}
|
|
} else {
|
|
newQueue.push(item);
|
|
}
|
|
}
|
|
|
|
if (mostRecentInterim) {
|
|
newQueue.push(mostRecentInterim);
|
|
}
|
|
|
|
translationQueue = newQueue;
|
|
|
|
// Take the first item from the queue
|
|
const nextTranslation = translationQueue.shift();
|
|
|
|
// Process this translation
|
|
worker.postMessage([
|
|
"translate",
|
|
nextTranslation.from,
|
|
nextTranslation.to,
|
|
nextTranslation.text,
|
|
null,
|
|
nextTranslation.interm
|
|
]);
|
|
|
|
// Store the ID to handle it in the worker response
|
|
currentTranslationId = nextTranslation.id;
|
|
}
|
|
|
|
const isSupported = (lngFrom, lngTo) => {
|
|
return true;
|
|
}
|
|
const loadModel = () => {
|
|
const lngFrom = langFrom.value;
|
|
const lngTo = langTo.value;
|
|
if (lngFrom !== lngTo) {
|
|
if (!isSupported(lngFrom, lngTo)) {
|
|
status("Language pair is not supported");
|
|
document.getElementById("output").innerText = "";
|
|
return;
|
|
}
|
|
currentModelPair = `${lngFrom}${lngTo}`;
|
|
modelReady = false;
|
|
translationQueue = [];
|
|
pendingTranslations.clear();
|
|
isTranslating = false;
|
|
clearPendingInterimTranslation();
|
|
resetInterimOutputStabilizer();
|
|
status(`Installing model...`);
|
|
console.log(`Loading model '${lngFrom}${lngTo}'`);
|
|
worker.postMessage(["load_model", lngFrom, lngTo]);
|
|
} else {
|
|
currentModelPair = "";
|
|
modelReady = true;
|
|
translationQueue = [];
|
|
pendingTranslations.clear();
|
|
isTranslating = false;
|
|
clearPendingInterimTranslation();
|
|
resetInterimOutputStabilizer();
|
|
const input = document.getElementById("input").value;
|
|
document.getElementById("output").innerText = "";
|
|
}
|
|
};
|
|
const findFirstSupportedTo = () => {
|
|
return Object.entries(supportedToCodes).find(([code, ]) => code !== langFrom.value)[0]
|
|
}
|
|
langFrom.addEventListener("change", e => {
|
|
const setToCode = (currentTo !== langFrom.value) ?
|
|
currentTo :
|
|
findFirstSupportedTo();
|
|
|
|
myLang = langFrom.value;
|
|
myLangCode = myLang.split("-")[0];
|
|
|
|
if (myLangCode in supportedFromCodes) {
|
|
console.log("setting input language to", myLang);
|
|
|
|
// Update URL parameter with full language code
|
|
updateURL("lang=" + myLang, true);
|
|
|
|
// Update recognition language with full language code
|
|
if (recognition) {
|
|
recognition.lang = myLang;
|
|
}
|
|
}
|
|
|
|
recognition.stop();
|
|
setLangs(langTo, supportedToCodes, setToCode, langFrom.value);
|
|
loadModel();
|
|
});
|
|
langTo.addEventListener("change", e => {
|
|
currentTo = langTo.value;
|
|
// Update URL parameter
|
|
updateURL("translate=" + currentTo, true);
|
|
loadModel();
|
|
});
|
|
const setLangs = (selector, langsToSet, value, exlcude) => {
|
|
selector.innerHTML = "";
|
|
for (const [code, type] of Object.entries(langsToSet)) {
|
|
if (code === exlcude) continue;
|
|
let name = langs[code];
|
|
if (type === "dev") name += " (Beta)";
|
|
selector.innerHTML += "<option value='" + code + "'>" + name + "</option>";
|
|
}
|
|
selector.value = value;
|
|
}
|
|
|
|
function init() {
|
|
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;
|
|
}
|
|
let setFromCode = "en";
|
|
if (myLang) {
|
|
myLangCode = myLang.split("-")[0];
|
|
if (myLangCode in supportedFromCodes) {
|
|
console.log("guessing input language is", myLangCode);
|
|
setFromCode = myLangCode;
|
|
}
|
|
}
|
|
setLangs(langFrom, supportedFromCodes, setFromCode, null);
|
|
const setToCode = targetCode;
|
|
setLangs(langTo, supportedToCodes, setToCode, setFromCode);
|
|
currentTo = setToCode;
|
|
loadModel();
|
|
}
|
|
|
|
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);
|
|
// Update urlParams with the new parameter
|
|
urlParams = new URLSearchParams(window.location.search);
|
|
}
|
|
} 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]);
|
|
}
|
|
window.history.pushState({
|
|
path: href.toString()
|
|
}, '', href.toString());
|
|
// Update urlParams with the modified parameters
|
|
urlParams = new URLSearchParams(window.location.search);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
var generateRandomString = function (LLL = 16) {
|
|
var text = "";
|
|
var words = ["the", "of", "to", "and", "a", "in", "is", "it", "you", "that", "he", "was", "for", "on", "are", "with", "as", "I", "his", "they", "be", "at", "one", "have", "this", "from", "or", "had", "by", "word", "but", "what", "some", "we", "can", "out", "other", "were", "all", "there", "when", "up", "use", "your", "how", "said", "an", "each", "she", "which", "do", "their", "time", "if", "will", "way", "about", "many", "then", "them", "write", "would", "like", "so", "these", "her", "long", "make", "thing", "see", "him", "two", "has", "look", "more", "day", "could", "go", "come", "did", "number", "sound", "no", "most", "people", "my", "over", "know", "water", "than", "call", "first", "who", "may", "down", "side", "been", "now", "find", "any", "new", "work", "part", "take", "get", "place", "made", "live", "where", "after", "back", "little", "only", "round", "man", "year", "came", "show", "every", "good", "me", "give", "our", "under", "name", "very", "through", "just", "form", "sentence", "great", "think", "say", "help", "low", "line", "differ", "turn", "cause", "much", "mean", "before", "move", "right", "boy", "old", "too", "same", "tell", "does", "set", "three", "want", "air", "well", "also", "play", "small", "end", "put", "home", "read", "hand", "port", "large", "spell", "add", "even", "land", "here", "must", "big", "high", "such", "follow", "act", "why", "ask", "men", "change", "went", "light", "kind", "off", "need", "house", "picture", "try", "us", "again", "animal", "point", "mother", "world", "near", "build", "self", "earth", "father", "head", "stand", "own", "page", "should", "country", "found", "answer", "school", "grow", "study", "still", "learn", "plant", "cover", "food", "sun", "four", "between", "state", "keep", "eye", "never", "last", "let", "thought", "city", "tree", "cross", "farm", "hard", "start", "might", "story", "saw", "far", "sea", "draw", "left", "late", "run", "dont", "while", "press", "close", "night", "real", "life", "few", "north", "open", "seem", "together", "next", "white", "children", "begin", "got", "walk", "example", "ease", "paper", "group", "always", "music", "those", "both", "mark", "often", "letter", "until", "mile", "river", "car", "feet", "care", "second", "book", "carry", "took", "science", "eat", "room", "friend", "began", "idea", "fish", "mountain", "stop", "once", "base", "hear", "horse", "cut", "sure", "watch", "color", "face", "wood", "main", "enough", "plain", "girl", "usual", "young", "ready", "above", "ever", "red", "list", "though", "feel", "talk", "bird", "soon", "body", "dog", "family", "direct", "pose", "leave", "song", "measure", "door", "product", "black", "short", "numeral", "class", "wind", "question", "happen", "complete", "ship", "area", "half", "rock", "order", "fire", "south", "problem", "piece", "told", "knew", "pass", "since", "top", "whole", "king", "space", "heard", "best", "hour", "better", "during", "hundred", "five", "remember", "step", "early", "hold", "west", "ground", "interest", "reach", "fast", "verb", "sing", "listen", "six", "table", "travel", "less", "morning", "ten", "simple", "several", "vowel", "toward", "war", "lay", "against", "pattern", "slow", "center", "love", "person", "money", "serve", "appear", "road", "map", "rain", "rule", "govern", "pull", "cold", "notice", "voice", "unit", "power", "town", "fine", "certain", "fly", "fall", "lead", "cry", "dark", "machine", "note", "wait", "plan", "figure", "star", "box", "noun", "field", "rest", "correct", "able", "pound", "done", "beauty", "drive", "stood", "contain", "front", "teach", "week", "final", "gave", "green", "oh", "quick", "develop", "ocean", "warm", "free", "minute", "strong", "special", "mind", "behind", "clear", "tail", "produce", "fact", "street", "inch", "multiply", "nothing", "course", "stay", "wheel", "full", "force", "blue", "object", "decide", "surface", "deep", "moon", "island", "foot", "system", "busy", "test", "record", "boat", "common", "gold", "possible", "plane", "stead", "dry", "wonder", "laugh", "thousand", "ago", "ran", "check", "game", "shape", "equate", "hot", "miss", "brought", "heat", "snow", "tire", "bring", "yes", "distant", "fill", "east", "paint", "language", "among", "grand", "ball", "yet", "wave", "drop", "heart", "am", "present", "heavy", "dance", "engine", "position", "arm", "wide", "sail", "material", "size", "vary", "settle", "speak", "weight", "general", "ice", "matter", "circle", "pair", "include", "divide", "syllable", "felt", "perhaps", "pick", "sudden", "count", "square", "reason", "length", "represent", "art", "subject", "region", "energy", "hunt", "probable", "bed", "brother", "egg", "ride", "cell", "believe", "fraction", "forest", "sit", "race", "window", "store", "summer", "train", "sleep", "prove", "lone", "leg", "exercise", "wall", "catch", "mount", "wish", "sky", "board", "joy", "winter", "sat", "written", "wild", "instrument", "kept", "glass", "grass", "cow", "job", "edge", "sign", "visit", "past", "soft", "fun", "bright", "gas", "weather", "month", "million", "bear", "finish", "happy", "hope", "flower", "clothe", "strange", "gone", "jump", "baby", "eight", "village", "meet", "root", "buy", "raise", "solve", "metal", "whether", "push", "seven", "paragraph", "third", "shall", "held", "hair", "describe", "cook", "floor", "either", "result", "burn", "hill", "safe", "cat", "century", "consider", "type", "law", "bit", "coast", "copy", "phrase", "silent", "tall", "sand", "soil", "roll", "temperature", "finger", "industry", "value", "fight", "lie", "beat", "excite", "natural", "view", "sense", "ear", "else", "quite", "broke", "case", "middle", "kill", "son", "lake", "moment", "scale", "loud", "spring", "observe", "child", "straight", "consonant", "nation", "dictionary", "milk", "speed", "method", "organ", "pay", "age", "section", "dress", "cloud", "surprise", "quiet", "stone", "tiny", "climb", "cool", "design", "poor", "lot", "experiment", "bottom", "key", "iron", "single", "stick", "flat", "twenty", "skin", "smile", "crease", "hole", "trade", "melody", "trip", "office", "receive", "row", "mouth", "exact", "symbol", "die", "least", "trouble", "shout", "except", "wrote", "seed", "tone", "join", "suggest", "clean", "break", "lady", "yard", "rise", "bad", "blow", "oil", "blood", "touch", "grew", "cent", "mix", "team", "wire", "cost", "lost", "brown", "wear", "garden", "equal", "sent", "choose", "fell", "fit", "flow", "fair", "bank", "collect", "save", "control", "decimal", "gentle", "woman", "captain", "practice", "separate", "difficult", "doctor", "please", "protect", "noon", "whose", "locate", "ring", "character", "insect", "caught", "period", "indicate", "radio", "spoke", "atom", "human", "history", "effect", "electric", "expect", "crop", "modern", "element", "hit", "student", "corner", "party", "supply", "bone", "rail", "imagine", "provide", "agree", "thus", "capital", "wont", "chair", "danger", "fruit", "rich", "thick", "soldier", "process", "operate", "guess", "necessary", "sharp", "wing", "create", "neighbor", "wash", "bat", "rather", "crowd", "corn", "compare", "poem", "string", "bell", "depend", "meat", "rub", "tube", "famous", "dollar", "stream", "fear", "sight", "thin", "triangle", "planet", "hurry", "chief", "colony", "clock", "mine", "tie", "enter", "major", "fresh", "search", "send", "yellow", "gun", "allow", "print", "dead", "spot", "desert", "suit", "current", "lift", "rose", "continue", "block", "chart", "hat", "sell", "success", "company", "subtract", "event", "particular", "deal", "swim", "term", "opposite", "wife", "shoe", "shoulder", "spread", "arrange", "camp", "invent", "cotton", "born", "determine", "quart", "nine", "truck", "noise", "level", "chance", "gather", "shop", "stretch", "throw", "shine", "property", "column", "molecule", "select", "wrong", "gray", "repeat", "require", "broad", "prepare", "salt", "nose", "plural", "anger", "claim", "continent", "oxygen", "sugar", "death", "pretty", "skill", "women", "season", "solution", "magnet", "silver", "thank", "branch", "match", "suffix", "especially", "fig", "afraid", "huge", "sister", "steel", "discuss", "forward", "similar", "guide", "experience", "score", "apple", "bought", "led", "pitch", "coat", "mass", "card", "band", "rope", "slip", "win", "dream", "evening", "condition", "feed", "tool", "total", "basic", "smell", "valley", "nor", "double", "seat", "arrive", "master", "track", "parent", "shore", "division", "sheet", "substance", "favor", "connect", "post", "spend", "chord", "fat", "glad", "original", "share", "station", "dad", "bread", "charge", "proper", "bar", "offer", "segment", "slave", "duck", "instant", "market", "degree", "populate", "chick", "dear", "enemy", "reply", "drink", "occur", "support", "speech", "nature", "range", "steam", "motion", "path", "liquid", "log", "meant", "quotient", "teeth", "shell", "neck"];
|
|
|
|
for (var i = 0; i < 2; i++) {
|
|
try {
|
|
var rndint = parseInt(Math.random() * 1000);
|
|
text += words[rndint]; // capitalizeFirstLetter can be used to improve security
|
|
} catch (e) {}
|
|
}
|
|
var possible = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
while (text.length < LLL) {
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
}
|
|
try {
|
|
text = text.replaceAll("AD", "vDAv"); // avoiding adblockers
|
|
text = text.replaceAll("Ad", "vdAv");
|
|
text = text.replaceAll("ad", "vdav");
|
|
text = text.replaceAll("aD", "vDav");
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
return text;
|
|
};
|
|
|
|
function generateStreamID() {
|
|
var text = "";
|
|
var possible = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
|
for (var i = 0; i < 7; i++) {
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
}
|
|
return text;
|
|
};
|
|
|
|
var roomID = "test";
|
|
if (urlParams.has("room")) {
|
|
roomID = urlParams.get("room");
|
|
} else if (urlParams.has("ROOM")) {
|
|
roomID = urlParams.get("ROOM");
|
|
} else {
|
|
roomID = generateRandomString();
|
|
updateURL("room=" + roomID);
|
|
}
|
|
|
|
// Check for insecure stream ID and show warning
|
|
if (isInsecureStreamId(roomID)) {
|
|
injectSecurityStyles();
|
|
showSecurityWarning(
|
|
'Warning: You are using an insecure room ID that could be easily guessed. Consider using a more secure ID for privacy.',
|
|
0 // No auto-dismiss, user must close manually
|
|
);
|
|
}
|
|
|
|
var url = document.URL.substr(0, document.URL.lastIndexOf('/'));
|
|
const overlayShareLink = url + "/overlay?room=" + encodeURIComponent(roomID);
|
|
document.getElementById("shareLink").href = overlayShareLink;
|
|
document.getElementById("shareLink").textContent = overlayShareLink;
|
|
navigator.clipboard.writeText(overlayShareLink).then(() => {}, () => {});
|
|
|
|
// Interim text handling options
|
|
var autoFinalMs = 0;
|
|
if (urlParams.has("autofinal")){
|
|
autoFinalMs = parseInt(urlParams.get("autofinal")) || 800;
|
|
} else if (urlParams.has("autofinalize")){
|
|
autoFinalMs = parseInt(urlParams.get("autofinalize")) || 800;
|
|
}
|
|
|
|
var maxWords = 0;
|
|
var maxChars = 0;
|
|
if (urlParams.has("maxwords")){
|
|
maxWords = parseInt(urlParams.get("maxwords")) || 50;
|
|
}
|
|
if (urlParams.has("maxchars")){
|
|
maxChars = parseInt(urlParams.get("maxchars")) || 200;
|
|
}
|
|
|
|
var autoSplit = urlParams.has("autosplit");
|
|
|
|
var silenceTimer = null;
|
|
var pendingInterim = "";
|
|
|
|
// Truncate interim text to keep only most recent words/chars
|
|
function truncateInterim(text) {
|
|
if (!text) return text;
|
|
|
|
if (maxChars > 0 && text.length > maxChars) {
|
|
text = "..." + text.slice(-maxChars);
|
|
}
|
|
|
|
if (maxWords > 0) {
|
|
const words = text.split(/\s+/);
|
|
if (words.length > maxWords) {
|
|
text = "..." + words.slice(-maxWords).join(' ');
|
|
}
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
// Extract complete sentences for auto-split
|
|
function extractCompleteSentences(text) {
|
|
const sentenceEndPattern = /([^.!?]*[.!?]+)\s+/g;
|
|
const matches = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
while ((match = sentenceEndPattern.exec(text)) !== null) {
|
|
matches.push(match[1].trim());
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
const remaining = text.slice(lastIndex).trim();
|
|
|
|
return {
|
|
complete: matches,
|
|
remaining: remaining
|
|
};
|
|
}
|
|
|
|
var wsPublisher = null;
|
|
|
|
function connectWebSocket() {
|
|
wsPublisher = createWSPublisher({
|
|
url: "wss://api.caption.ninja:443",
|
|
room: roomID,
|
|
onStateChange: function(nextState, snapshot) {
|
|
if (nextState === "error") {
|
|
console.error("WebSocket error state:", snapshot && snapshot.lastError);
|
|
}
|
|
if (snapshot && snapshot.blockedSuspected) {
|
|
console.warn("WebSocket may be blocked by VPN/firewall/region filters.");
|
|
}
|
|
},
|
|
onError: function(error) {
|
|
console.error("WebSocket error:", error);
|
|
}
|
|
});
|
|
wsPublisher.connect();
|
|
}
|
|
|
|
const ttsMenu = {
|
|
init() {
|
|
this.enableTTS = document.getElementById('enableTTS');
|
|
this.ttsOptions = document.getElementById('tts-options');
|
|
this.ttsProvider = document.getElementById('tts-provider');
|
|
this.providerOptions = {
|
|
system: document.getElementById('system-options'),
|
|
elevenlabs: document.getElementById('elevenlabs-options'),
|
|
google: document.getElementById('google-options'),
|
|
speechify: document.getElementById('speechify-options')
|
|
};
|
|
|
|
// Initialize system voices if available
|
|
if ('speechSynthesis' in window) {
|
|
const loadVoices = () => {
|
|
const voices = speechSynthesis.getVoices();
|
|
const ttsVoice = document.getElementById('tts-voice');
|
|
if (!ttsVoice) return;
|
|
|
|
ttsVoice.innerHTML = '<option value="">System Default Voice</option>';
|
|
voices.forEach(voice => {
|
|
const option = document.createElement('option');
|
|
option.value = voice.name;
|
|
option.textContent = `${voice.name} (${voice.lang})`;
|
|
ttsVoice.appendChild(option);
|
|
});
|
|
};
|
|
|
|
if (speechSynthesis.onvoiceschanged !== undefined) {
|
|
speechSynthesis.onvoiceschanged = loadVoices;
|
|
}
|
|
|
|
// Initial load attempt
|
|
const voices = speechSynthesis.getVoices();
|
|
if (voices.length) {
|
|
loadVoices();
|
|
}
|
|
}
|
|
|
|
// Setup event listeners
|
|
this.enableTTS.addEventListener('change', () => {
|
|
this.ttsOptions.style.display = this.enableTTS.checked ? 'block' : 'none';
|
|
this.updateShareLink();
|
|
checkForConflictingSettings();
|
|
});
|
|
|
|
this.ttsProvider.addEventListener('change', () => {
|
|
Object.values(this.providerOptions).forEach(el => el.style.display = 'none');
|
|
this.providerOptions[this.ttsProvider.value].style.display = 'block';
|
|
this.updateShareLink();
|
|
});
|
|
|
|
// Add change listeners to all inputs
|
|
document.querySelectorAll('#tts-options input, #tts-options select').forEach(el => {
|
|
el.addEventListener('change', () => this.updateShareLink());
|
|
});
|
|
|
|
// Initialize from URL params
|
|
this.initFromURL();
|
|
},
|
|
|
|
initFromURL() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (!urlParams.has('tts')) return;
|
|
|
|
this.enableTTS.checked = true;
|
|
this.ttsOptions.style.display = 'block';
|
|
|
|
// Determine provider from URL params
|
|
if (urlParams.has('elevenlabskey')) {
|
|
this.ttsProvider.value = 'elevenlabs';
|
|
document.getElementById('elevenlabs-key').value = urlParams.get('elevenlabskey');
|
|
if (urlParams.has('voice11')) document.getElementById('elevenlabs-voice').value = urlParams.get('voice11');
|
|
if (urlParams.has('elevenlabsmodel')) document.getElementById('elevenlabs-model').value = urlParams.get('elevenlabsmodel');
|
|
if (urlParams.has('elevenrate')) document.getElementById('elevenlabs-rate').value = urlParams.get('elevenrate');
|
|
document.getElementById('elevenlabs-speaker-boost').checked = urlParams.has('elevenspeakerboost');
|
|
} else if (urlParams.has('ttskey')) {
|
|
this.ttsProvider.value = 'google';
|
|
document.getElementById('google-key').value = urlParams.get('ttskey');
|
|
if (urlParams.has('voicegoogle')) document.getElementById('google-voice').value = urlParams.get('voicegoogle');
|
|
if (urlParams.has('googlerate')) document.getElementById('google-rate').value = urlParams.get('googlerate');
|
|
if (urlParams.has('googlepitch')) document.getElementById('google-pitch').value = urlParams.get('googlepitch');
|
|
if (urlParams.has('googleaudioprofile')) document.getElementById('google-profile').value = urlParams.get('googleaudioprofile');
|
|
} else if (urlParams.has('speechifykey')) {
|
|
this.ttsProvider.value = 'speechify';
|
|
document.getElementById('speechify-key').value = urlParams.get('speechifykey');
|
|
if (urlParams.has('voicespeechify')) document.getElementById('speechify-voice').value = urlParams.get('voicespeechify');
|
|
if (urlParams.has('speechifyspeed')) document.getElementById('speechify-speed').value = urlParams.get('speechifyspeed');
|
|
if (urlParams.has('speechifymodel')) document.getElementById('speechify-model').value = urlParams.get('speechifymodel');
|
|
} else {
|
|
// System TTS params
|
|
if (urlParams.has('voice')) document.getElementById('tts-voice').value = urlParams.get('voice');
|
|
if (urlParams.has('rate')) document.getElementById('tts-rate').value = urlParams.get('rate');
|
|
if (urlParams.has('pitch')) document.getElementById('tts-pitch').value = urlParams.get('pitch');
|
|
if (urlParams.has('volume')) document.getElementById('tts-volume').value = urlParams.get('volume');
|
|
}
|
|
|
|
// Show correct provider options
|
|
Object.values(this.providerOptions).forEach(el => el.style.display = 'none');
|
|
this.providerOptions[this.ttsProvider.value].style.display = 'block';
|
|
},
|
|
|
|
updateShareLink() {
|
|
const shareLink = document.getElementById('shareLink');
|
|
const currentUrl = new URL(shareLink.href);
|
|
const params = new URLSearchParams(currentUrl.search);
|
|
|
|
// Clear existing TTS params
|
|
['tts', 'voice', 'rate', 'pitch', 'volume',
|
|
'elevenlabskey', 'voice11', 'elevenlabsmodel', 'elevenrate', 'elevenspeakerboost',
|
|
'ttskey', 'voicegoogle', 'googlerate', 'googlepitch', 'googleaudioprofile',
|
|
'speechifykey', 'voicespeechify', 'speechifyspeed', 'speechifymodel'
|
|
].forEach(param => {
|
|
params.delete(param);
|
|
});
|
|
|
|
if (this.enableTTS.checked) {
|
|
params.set('tts', '');
|
|
|
|
const provider = this.ttsProvider.value;
|
|
switch (provider) {
|
|
case 'elevenlabs':
|
|
const elevenKey = document.getElementById('elevenlabs-key').value;
|
|
if (elevenKey) {
|
|
params.set('elevenlabskey', elevenKey);
|
|
const elevenVoice = document.getElementById('elevenlabs-voice').value;
|
|
if (elevenVoice) params.set('voice11', elevenVoice);
|
|
params.set('elevenlabsmodel', document.getElementById('elevenlabs-model').value);
|
|
params.set('elevenrate', document.getElementById('elevenlabs-rate').value);
|
|
if (document.getElementById('elevenlabs-speaker-boost').checked) {
|
|
params.set('elevenspeakerboost', '');
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'google':
|
|
const googleKey = document.getElementById('google-key').value;
|
|
if (googleKey) {
|
|
params.set('ttskey', googleKey);
|
|
const googleVoice = document.getElementById('google-voice').value;
|
|
if (googleVoice) params.set('voicegoogle', googleVoice);
|
|
params.set('googlerate', document.getElementById('google-rate').value);
|
|
params.set('googlepitch', document.getElementById('google-pitch').value);
|
|
params.set('googleaudioprofile', document.getElementById('google-profile').value);
|
|
}
|
|
break;
|
|
|
|
case 'speechify':
|
|
const speechifyKey = document.getElementById('speechify-key').value;
|
|
if (speechifyKey) {
|
|
params.set('speechifykey', speechifyKey);
|
|
const speechifyVoice = document.getElementById('speechify-voice').value;
|
|
if (speechifyVoice) params.set('voicespeechify', speechifyVoice);
|
|
params.set('speechifyspeed', document.getElementById('speechify-speed').value);
|
|
params.set('speechifymodel', document.getElementById('speechify-model').value);
|
|
}
|
|
break;
|
|
|
|
default: // system
|
|
const voice = document.getElementById('tts-voice').value;
|
|
if (voice) params.set('voice', voice);
|
|
if (document.getElementById('tts-rate').value !== '1.0') {
|
|
params.set('rate', document.getElementById('tts-rate').value);
|
|
}
|
|
if (document.getElementById('tts-pitch').value !== '1.0') {
|
|
params.set('pitch', document.getElementById('tts-pitch').value);
|
|
}
|
|
if (document.getElementById('tts-volume').value !== '1.0') {
|
|
params.set('volume', document.getElementById('tts-volume').value);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
currentUrl.search = params.toString();
|
|
shareLink.href = currentUrl.toString();
|
|
shareLink.textContent = currentUrl.toString();
|
|
|
|
// Add notification about needing to recopy link
|
|
shareLink.style.backgroundColor = "#fff3cd";
|
|
shareLink.style.padding = "3px";
|
|
shareLink.title = "TTS settings updated - copy new link to update overlay";
|
|
|
|
// Clear highlight after 2 seconds
|
|
setTimeout(() => {
|
|
shareLink.style.backgroundColor = "";
|
|
shareLink.style.padding = "";
|
|
}, 2000);
|
|
|
|
// Update clipboard
|
|
navigator.clipboard.writeText(currentUrl.toString()).catch(err => console.error('Failed to copy URL:', err));
|
|
|
|
// Show temporary notification
|
|
const status = document.getElementById('status');
|
|
if (status) {
|
|
const originalText = status.textContent;
|
|
status.textContent = "TTS settings updated - copy new overlay link to apply changes";
|
|
setTimeout(() => {
|
|
status.textContent = originalText;
|
|
}, 3000);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
connectWebSocket();
|
|
|
|
window.copyWsDebugInfo = function () {
|
|
if (!wsPublisher) return;
|
|
var snapshot = wsPublisher.getSnapshot();
|
|
var text = "[Caption.Ninja WS Debug]\\n" +
|
|
"room=" + roomID + "\\n" +
|
|
"state=" + snapshot.state + "\\n" +
|
|
"retries=" + snapshot.retryCount + "\\n" +
|
|
"queue=" + snapshot.queueLength + "\\n" +
|
|
"dropped=" + snapshot.droppedCount + "\\n" +
|
|
"blocked=" + snapshot.blockedSuspected + "\\n" +
|
|
"lastError=" + (snapshot.lastError || "") + "\\n" +
|
|
"userAgent=" + navigator.userAgent;
|
|
navigator.clipboard.writeText(text).catch(function(){});
|
|
};
|
|
|
|
|
|
var transcript = '';
|
|
var last_transcription = "";
|
|
var second_transcription = "";
|
|
var idle = null;
|
|
var recognition = null;
|
|
var lastRecognitionError = "";
|
|
var recognitionRecoveryTimer = null;
|
|
var defaultFinalCoalesceMs = /Android/i.test(navigator.userAgent || "") ? 900 : 0;
|
|
var finalCoalesceMs = defaultFinalCoalesceMs;
|
|
if (urlParams.has("finaldebounce")) {
|
|
finalCoalesceMs = parseInt(urlParams.get("finaldebounce"), 10);
|
|
if (isNaN(finalCoalesceMs) || finalCoalesceMs < 0) {
|
|
finalCoalesceMs = defaultFinalCoalesceMs;
|
|
}
|
|
} else if (urlParams.has("finalcoalesce")) {
|
|
finalCoalesceMs = parseInt(urlParams.get("finalcoalesce"), 10);
|
|
if (isNaN(finalCoalesceMs) || finalCoalesceMs < 0) {
|
|
finalCoalesceMs = defaultFinalCoalesceMs;
|
|
}
|
|
}
|
|
var pendingFinalSource = "";
|
|
var pendingFinalTimer = null;
|
|
setup();
|
|
var ends = 0;
|
|
var sanitize = function(string) {
|
|
var temp = document.createElement('div');
|
|
temp.textContent = string;
|
|
return applyTextRules(temp.textContent
|
|
.substring(0, Math.min(temp.textContent.length, 500))
|
|
.trim());
|
|
};
|
|
|
|
function normalizeFinalForCompare(text) {
|
|
return (text || "")
|
|
.toLowerCase()
|
|
.replace(/[.,!?;:]+/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function isProgressiveFinalText(previousText, nextText) {
|
|
const previous = normalizeFinalForCompare(previousText);
|
|
const next = normalizeFinalForCompare(nextText);
|
|
if (!previous || !next || previous === next) {
|
|
return previous === next;
|
|
}
|
|
if (!next.startsWith(previous)) {
|
|
return false;
|
|
}
|
|
const boundary = next.charAt(previous.length);
|
|
return boundary === "" || /\s/.test(boundary);
|
|
}
|
|
|
|
function clearPendingFinalTimer() {
|
|
if (pendingFinalTimer) {
|
|
clearTimeout(pendingFinalTimer);
|
|
pendingFinalTimer = null;
|
|
}
|
|
}
|
|
|
|
function publishFinalSource(sourceText) {
|
|
if (!sourceText) {
|
|
return;
|
|
}
|
|
|
|
if (document.getElementById("fullContext").checked) {
|
|
translateCall([second_transcription, last_transcription, sourceText]
|
|
.filter(Boolean)
|
|
.join(' '), false);
|
|
} else {
|
|
translateCall(sourceText, false);
|
|
}
|
|
|
|
second_transcription = last_transcription;
|
|
last_transcription = sourceText + ". ";
|
|
}
|
|
|
|
function flushPendingFinalSource() {
|
|
clearPendingFinalTimer();
|
|
const sourceText = pendingFinalSource;
|
|
pendingFinalSource = "";
|
|
publishFinalSource(sourceText);
|
|
}
|
|
|
|
function scheduleFinalSource(sourceText) {
|
|
sourceText = (sourceText || "").trim();
|
|
if (!sourceText) {
|
|
return;
|
|
}
|
|
|
|
if (!finalCoalesceMs) {
|
|
flushPendingFinalSource();
|
|
publishFinalSource(sourceText);
|
|
return;
|
|
}
|
|
|
|
if (pendingFinalSource) {
|
|
if (isProgressiveFinalText(pendingFinalSource, sourceText)) {
|
|
pendingFinalSource = sourceText;
|
|
clearPendingFinalTimer();
|
|
pendingFinalTimer = setTimeout(flushPendingFinalSource, finalCoalesceMs);
|
|
return;
|
|
}
|
|
if (isProgressiveFinalText(sourceText, pendingFinalSource)) {
|
|
clearPendingFinalTimer();
|
|
pendingFinalTimer = setTimeout(flushPendingFinalSource, finalCoalesceMs);
|
|
return;
|
|
}
|
|
|
|
flushPendingFinalSource();
|
|
}
|
|
|
|
pendingFinalSource = sourceText;
|
|
pendingFinalTimer = setTimeout(flushPendingFinalSource, finalCoalesceMs);
|
|
}
|
|
|
|
function holdPendingFinalIfInterimContinues(interimText) {
|
|
if (!pendingFinalSource || !finalCoalesceMs || !interimText) {
|
|
return;
|
|
}
|
|
if (isProgressiveFinalText(pendingFinalSource, interimText)) {
|
|
clearPendingFinalTimer();
|
|
pendingFinalTimer = setTimeout(flushPendingFinalSource, finalCoalesceMs);
|
|
} else {
|
|
flushPendingFinalSource();
|
|
}
|
|
}
|
|
|
|
function scheduleRecognitionRecovery(delayMs, rebuildRecognizer = false) {
|
|
if (recognitionRecoveryTimer) {
|
|
clearTimeout(recognitionRecoveryTimer);
|
|
}
|
|
|
|
recognitionRecoveryTimer = setTimeout(function() {
|
|
recognitionRecoveryTimer = null;
|
|
if (isPaused) {
|
|
return;
|
|
}
|
|
|
|
if (rebuildRecognizer) {
|
|
const staleRecognition = recognition;
|
|
if (staleRecognition) {
|
|
staleRecognition.onstart = null;
|
|
staleRecognition.onerror = null;
|
|
staleRecognition.onend = null;
|
|
staleRecognition.onresult = null;
|
|
}
|
|
recognition = null;
|
|
setup();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
recognition.start();
|
|
} catch (restartError) {
|
|
console.error("Error restarting recognition:", restartError);
|
|
if (useSpeechModeV2) {
|
|
recognition = null;
|
|
setup();
|
|
}
|
|
}
|
|
}, delayMs);
|
|
}
|
|
|
|
function setup() {
|
|
if ('webkitSpeechRecognition' in window) {
|
|
recognition = new webkitSpeechRecognition();
|
|
if (myLang && myLangCode) {
|
|
if (myLang.split("-")[0] == myLangCode) {
|
|
recognition.lang = myLang;
|
|
} else {
|
|
recognition.lang = myLangCode;
|
|
}
|
|
} else if (myLangCode) {
|
|
recognition.lang = myLangCode;
|
|
} else if (myLang) {
|
|
recognition.lang = myLang;
|
|
}
|
|
recognition.continuous = true;
|
|
recognition.interimResults = true;
|
|
recognition.onstart = function() {
|
|
lastRecognitionError = "";
|
|
console.log("started transcription");
|
|
setTimeout(function() {
|
|
ends = 0;
|
|
}, 2000);
|
|
};
|
|
recognition.onerror = function(event) {
|
|
console.error(event);
|
|
if (useSpeechModeV2 && event && event.error === "network") {
|
|
lastRecognitionError = "network";
|
|
}
|
|
};
|
|
recognition.onend = function(e) {
|
|
if (isPaused) {
|
|
return;
|
|
}
|
|
console.log("Stopped transcription");
|
|
|
|
if (useSpeechModeV2 && pendingInterim) {
|
|
forceFinalization();
|
|
}
|
|
|
|
// Save any pending transcript before restarting
|
|
if (transcript) {
|
|
scheduleFinalSource(transcript);
|
|
transcript = "";
|
|
}
|
|
|
|
const endEventType = (e && e.type) ? e.type : ((typeof event !== "undefined" && event && event.type) ? event.type : "");
|
|
if (endEventType === "end") {
|
|
ends += 1;
|
|
}
|
|
const shouldRebuildRecognizer = useSpeechModeV2 && lastRecognitionError === "network";
|
|
lastRecognitionError = "";
|
|
if (ends > 3) {
|
|
alert("WARNING: Cannot enable transcription service...");
|
|
} else {
|
|
if (shouldRebuildRecognizer) {
|
|
console.log("Rebuilding speech recognition after network error");
|
|
scheduleRecognitionRecovery(750, true);
|
|
} else {
|
|
scheduleRecognitionRecovery(useSpeechModeV2 ? 300 : 100);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Force finalization of pending interim text
|
|
function forceFinalization() {
|
|
if (!pendingInterim) return;
|
|
|
|
transcript += (transcript ? ' ' : '') + pendingInterim;
|
|
document.getElementById("input").value = transcript;
|
|
console.log("auto-finalized transcript:", transcript);
|
|
|
|
scheduleFinalSource(transcript);
|
|
|
|
// Clear finalized state; source history updates when the final is published.
|
|
transcript = "";
|
|
pendingInterim = "";
|
|
silenceTimer = null;
|
|
clearPendingInterimTranslation();
|
|
}
|
|
|
|
function processFinalTranscript(finalText) {
|
|
if (!finalText) {
|
|
return;
|
|
}
|
|
|
|
if (silenceTimer) {
|
|
clearTimeout(silenceTimer);
|
|
silenceTimer = null;
|
|
}
|
|
pendingInterim = "";
|
|
clearPendingInterimTranslation();
|
|
|
|
transcript += (transcript ? ' ' : '') + finalText;
|
|
document.getElementById("input").value = transcript;
|
|
console.log("final transcript:", transcript);
|
|
|
|
scheduleFinalSource(transcript);
|
|
|
|
transcript = "";
|
|
}
|
|
|
|
function processInterimTranscript(interimText) {
|
|
let textToProcess = interimText;
|
|
|
|
if (autoSplit) {
|
|
const { complete, remaining } = extractCompleteSentences(textToProcess);
|
|
|
|
for (const sentence of complete) {
|
|
processFinalTranscript(sentence);
|
|
}
|
|
|
|
textToProcess = remaining;
|
|
}
|
|
|
|
textToProcess = truncateInterim(textToProcess);
|
|
trackInterimInputRate(textToProcess);
|
|
textToProcess = maybePromoteInterimPrefix(textToProcess, processFinalTranscript);
|
|
|
|
if (textToProcess) {
|
|
pendingInterim = textToProcess;
|
|
if (autoFinalMs > 0) {
|
|
if (silenceTimer) clearTimeout(silenceTimer);
|
|
silenceTimer = setTimeout(forceFinalization, autoFinalMs);
|
|
}
|
|
|
|
let display_text = transcript + (transcript ? ' ' : '') + textToProcess;
|
|
document.getElementById("input").value = display_text;
|
|
holdPendingFinalIfInterimContinues(display_text);
|
|
|
|
if (translationQueue.length > 3) {
|
|
if (useSpeechModeV2) {
|
|
enterInterimBacklogMode();
|
|
translationQueue = translationQueue.filter(item => {
|
|
if (item.interm) {
|
|
pendingTranslations.delete(item.id);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
} else {
|
|
console.log("Skipping interim translation due to backlog");
|
|
return;
|
|
}
|
|
}
|
|
|
|
let text_to_translate;
|
|
if (document.getElementById("fullContext").checked) {
|
|
text_to_translate = [second_transcription, last_transcription, display_text]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
} else {
|
|
text_to_translate = display_text;
|
|
}
|
|
|
|
enqueueInterimTranslation(text_to_translate);
|
|
} else {
|
|
pendingInterim = "";
|
|
clearPendingInterimTranslation();
|
|
if (silenceTimer) {
|
|
clearTimeout(silenceTimer);
|
|
silenceTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replace the entire recognition.onresult handler with this
|
|
recognition.onresult = function(event) {
|
|
counter += 1;
|
|
|
|
if (typeof(event.results) == 'undefined') {
|
|
console.log(event);
|
|
return;
|
|
}
|
|
|
|
// Auto-dismiss security warning when we get actual speech results
|
|
const warningElement = document.getElementById('stream-security-warning');
|
|
if (warningElement) {
|
|
warningElement.style.animation = 'fadeOut 0.3s ease-out';
|
|
setTimeout(() => warningElement.remove(), 300);
|
|
}
|
|
|
|
// Track what's being processed in this batch
|
|
let isFinal = false;
|
|
let current_batch = '';
|
|
|
|
// Process only the new results since last event
|
|
for (var i = event.resultIndex; i < event.results.length; ++i) {
|
|
if (event.results[i].isFinal) {
|
|
isFinal = true;
|
|
}
|
|
if (event.results[i][0].transcript) {
|
|
current_batch += ' ' + sanitize(event.results[i][0].transcript);
|
|
}
|
|
}
|
|
|
|
current_batch = current_batch.trim();
|
|
|
|
if (!current_batch) {
|
|
return;
|
|
}
|
|
|
|
if (useSpeechModeV2) {
|
|
let currentBatchFinal = '';
|
|
let currentBatchInterim = '';
|
|
|
|
for (var j = event.resultIndex; j < event.results.length; ++j) {
|
|
if (!event.results[j][0].transcript) {
|
|
continue;
|
|
}
|
|
const cleanedTranscript = sanitize(event.results[j][0].transcript);
|
|
if (!cleanedTranscript) {
|
|
continue;
|
|
}
|
|
if (event.results[j].isFinal) {
|
|
currentBatchFinal += (currentBatchFinal ? ' ' : '') + cleanedTranscript;
|
|
} else {
|
|
currentBatchInterim += (currentBatchInterim ? ' ' : '') + cleanedTranscript;
|
|
}
|
|
}
|
|
|
|
if (currentBatchFinal) {
|
|
processFinalTranscript(currentBatchFinal);
|
|
}
|
|
if (currentBatchInterim) {
|
|
processInterimTranscript(currentBatchInterim);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update the UI to show what's being recognized
|
|
if (isFinal) {
|
|
processFinalTranscript(current_batch);
|
|
} else {
|
|
processInterimTranscript(current_batch);
|
|
}
|
|
};
|
|
if (isPaused) {
|
|
return;
|
|
}
|
|
recognition.start();
|
|
}
|
|
}
|
|
|
|
function checkForConflictingSettings() {
|
|
const enableTTS = document.getElementById('enableTTS');
|
|
const fullContext = document.getElementById('fullContext');
|
|
|
|
if (enableTTS.checked && fullContext.checked) {
|
|
alert('Warning: Using TTS with added context will cause double speak.');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
ttsMenu.init();
|
|
|
|
// Initialize interim handling controls from URL params
|
|
const autoFinalInput = document.getElementById('autoFinalInput');
|
|
const autoSplitInput = document.getElementById('autoSplitInput');
|
|
const maxWordsInput = document.getElementById('maxWordsInput');
|
|
const maxCharsInput = document.getElementById('maxCharsInput');
|
|
const legacySpeechModeInput = document.getElementById('legacySpeechMode');
|
|
|
|
if (autoFinalMs > 0) autoFinalInput.value = autoFinalMs;
|
|
if (autoSplit) autoSplitInput.checked = true;
|
|
if (maxWords > 0) maxWordsInput.value = maxWords;
|
|
if (maxChars > 0) maxCharsInput.value = maxChars;
|
|
legacySpeechModeInput.checked = useLegacySpeechMode;
|
|
|
|
// Update variables when controls change
|
|
autoFinalInput.addEventListener('change', function() {
|
|
autoFinalMs = parseInt(this.value) || 0;
|
|
});
|
|
autoSplitInput.addEventListener('change', function() {
|
|
autoSplit = this.checked;
|
|
});
|
|
maxWordsInput.addEventListener('change', function() {
|
|
maxWords = parseInt(this.value) || 0;
|
|
});
|
|
maxCharsInput.addEventListener('change', function() {
|
|
maxChars = parseInt(this.value) || 0;
|
|
});
|
|
legacySpeechModeInput.addEventListener('change', function() {
|
|
const nextUrl = new URL(window.location.href);
|
|
if (this.checked) {
|
|
nextUrl.searchParams.set('speechmode', 'legacy');
|
|
} else {
|
|
nextUrl.searchParams.delete('speechmode');
|
|
}
|
|
window.location.href = nextUrl.toString();
|
|
});
|
|
});
|
|
document.getElementById('fullContext').addEventListener('change', checkForConflictingSettings);
|
|
</script>
|
|
</body>
|
|
</html>
|