mirror of
https://github.com/steveseguin/captionninja.git
synced 2026-10-09 09:39:31 +00:00
300 lines
9.3 KiB
HTML
300 lines
9.3 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" />
|
|
<!-- Primary Meta Tags -->
|
|
<title>CAPTION.Ninja</title>
|
|
<meta name="title" content="CAPTION.Ninja" />
|
|
<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;
|
|
width: 100%;
|
|
}
|
|
|
|
.output {
|
|
margin:0;
|
|
color: black;
|
|
font-family: Cousine, monospace;
|
|
font-size: 3.2em;
|
|
line-height: 1.1em;
|
|
letter-spacing: 0.0em;
|
|
text-transform: uppercase;
|
|
padding: 0em;
|
|
}
|
|
|
|
.output span {
|
|
padding: 8px 8px 0px 8px;
|
|
margin:0;
|
|
display: block;
|
|
}
|
|
|
|
#interm {
|
|
color:blue;
|
|
margin:5px;
|
|
}
|
|
|
|
a {
|
|
color:blue;
|
|
font-size:1.2em;
|
|
text-transform: none;
|
|
}
|
|
|
|
input {
|
|
width: 100%;
|
|
|
|
padding: 20px;
|
|
margin: 10px 0;
|
|
font-size: 2em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<input type="textarea" id="userinput" />
|
|
<div id="interm" class="output">
|
|
<font style='color:black;font-size:60%;line-height: 1.4em;' ><div >This is a free-to-use captioning tool.</div></font><font style='color:black;font-size:30%;line-height: 1.4em;' >
|
|
<div ><br /><br />
|
|
The output of this app is mirrored here: <a id="shareLink" href="overlay.html" target='_blank'>*ERROR GENERATING LINK*</a>. Add it to OBS as a browser source overlay if wishing to use it for a live stream.
|
|
<br /><br />
|
|
For vMix, use this no-highlight overlay link: <a id="vmixLink" href="vmix.html" target='_blank'>*ERROR GENERATING LINK*</a>.
|
|
<br /><br />
|
|
Overlay style options include <code>&nobg</code> or <code>&bg=transparent</code> to remove the black highlight, <code>&bg=COLOR</code> to set it, <code>&color=COLOR</code>, <code>&fontsize=SIZE</code>, <code>&scale=N</code>, <code>&align=center</code>, <code>&valign=top</code>, <code>&shadow=none</code>, and <code>&outline=2&outlinecolor=%23000</code>.
|
|
</div></font>
|
|
<br /><br />
|
|
</div>
|
|
<div id="output" class="output"></div>
|
|
<script src="tts-integration.js"></script>
|
|
<script src="ws-publisher.js"></script>
|
|
<script src="text-rules.js"></script>
|
|
<script>
|
|
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
(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);
|
|
|
|
// Secure alphanumeric room ID generator (>=12 chars)
|
|
function generateSecureRoomID(len = 16) {
|
|
var text = "";
|
|
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
for (var i = 0; i < len; i++){
|
|
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
|
}
|
|
return text;
|
|
};
|
|
|
|
var label = false;
|
|
|
|
if (urlParams.has("label")){
|
|
label = urlParams.get("label");
|
|
}
|
|
|
|
var roomID = "test";
|
|
|
|
if (urlParams.has("room")){
|
|
roomID = urlParams.get("room");
|
|
} else if (urlParams.has("ROOM")){
|
|
roomID = urlParams.get("ROOM");
|
|
} else {
|
|
var storedRoom = "";
|
|
try { storedRoom = localStorage.getItem('cn_room_id') || ""; } catch (e) {}
|
|
if (storedRoom && /^[A-Za-z0-9]{12,}$/.test(storedRoom)) {
|
|
roomID = storedRoom;
|
|
updateURL("room="+roomID);
|
|
} else {
|
|
roomID = generateSecureRoomID(16);
|
|
updateURL("room="+roomID);
|
|
}
|
|
}
|
|
try { localStorage.setItem('cn_room_id', roomID); } catch (e) {}
|
|
|
|
|
|
var counter=0;
|
|
var url = document.URL.substr(0,document.URL.lastIndexOf('/'));
|
|
|
|
document.getElementById("shareLink").href= url+"/overlay?room="+roomID;
|
|
document.getElementById("shareLink").innerHTML = url+"/overlay?room="+roomID;
|
|
document.getElementById("vmixLink").href= url+"/vmix.html?room="+roomID;
|
|
document.getElementById("vmixLink").innerHTML = url+"/vmix.html?room="+roomID;
|
|
|
|
navigator.clipboard.writeText(url+"/overlay?room="+roomID).then(() => {
|
|
/* clipboard successfully set */
|
|
}, () => {
|
|
/* clipboard write failed */
|
|
});
|
|
|
|
var apiKey = false;
|
|
var wsPublisher = null;
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 network or firewall.");
|
|
}
|
|
},
|
|
onError: function(error) {
|
|
console.error("WebSocket error:", error);
|
|
}
|
|
});
|
|
wsPublisher.connect();
|
|
}
|
|
|
|
// Initial connection
|
|
connectWebSocket();
|
|
|
|
function interResults(){
|
|
counter+=1;
|
|
var interim_transcript = applyTextRules(document.getElementById("userinput").value);
|
|
document.getElementById("interm").textContent = interim_transcript;
|
|
if (wsPublisher) {
|
|
wsPublisher.publish({"msg":true, "interm":interim_transcript, "id":counter, "label":label});
|
|
}
|
|
try {
|
|
if (window.CAPTION_TTS && window.CAPTION_TTS.stream && typeof window.CAPTION_TTS.speak === 'function') {
|
|
window.CAPTION_TTS.speak(interim_transcript, true);
|
|
}
|
|
} catch (e) { console.warn(e); }
|
|
}
|
|
|
|
function finalResults(){
|
|
counter+=1;
|
|
var final_transcript = applyTextRules(document.getElementById("userinput").value);
|
|
if (wsPublisher) {
|
|
if (final_transcript==""){
|
|
wsPublisher.publish({"msg":true, "final":"", "id":counter, "label":label});
|
|
} else {
|
|
wsPublisher.publish({"msg":true, "final":final_transcript, "id":counter, "label":label});
|
|
}
|
|
}
|
|
try {
|
|
if (window.CAPTION_TTS && typeof window.CAPTION_TTS.speak === 'function') {
|
|
window.CAPTION_TTS.speak(final_transcript);
|
|
}
|
|
} catch (e) { console.warn(e); }
|
|
document.getElementById("output").appendChild(document.createTextNode(final_transcript));
|
|
document.getElementById("interm").textContent="";
|
|
document.getElementById("userinput").value = "";
|
|
}
|
|
|
|
document.getElementById("userinput").addEventListener("keyup", function(event) {
|
|
if (event.code === 'Enter') {
|
|
finalResults();
|
|
} else {
|
|
interResults();
|
|
}
|
|
});
|
|
|
|
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(){});
|
|
};
|
|
|
|
// Optional: open a separate TTS window powered by tts.rocks bridge
|
|
try {
|
|
if (urlParams.has('ttspopout')) {
|
|
var qs = new URLSearchParams(window.location.search);
|
|
qs.set('room', roomID);
|
|
var base = window.location.origin + window.location.pathname.replace(/\/[^^/]*$/, '');
|
|
var bridgeUrl = base + '/tts.rocks/caption-bridge.html?' + qs.toString();
|
|
window.open(bridgeUrl, '_blank', 'noopener');
|
|
}
|
|
} catch (e) { console.warn(e); }
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|