// ==UserScript== // @name Voice Control for ChatGPT // @name-CN ChatGPT 语音助手 // @namespace http://tampermonkey.net/ // @version 0.1.0 // @description Expands ChatGPT with voice control and read aloud. fork from https://chrome.google.com/webstore/detail/eollffkcakegifhacjnlnegohfdlidhn // @author You // @match https://chat.openai.com/* // @match https://chat-shared1.zhile.io/c/* // @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com // @grant none // @run-at document-idle // @downloadURL none // ==/UserScript== (function () { 'use strict'; // Your code here... const e = document.createElement("style"); var n; e.innerHTML = '\n#sai-root {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-top: 10px;\n}\n\n#sai-input-wrapper {\n position: relative;\n cursor: pointer;\n background-color: #e02d2d;\n animation-name: red-pulsating-color;\n animation-duration: 2s;\n animation-iteration-count: infinite;\n max-width: 75%;\n}\n\n#sai-input-wrapper:hover {\n opacity: 0.7;\n}\n\n#sai-input-wrapper div.w-full {\n padding-right: 35px;\n}\n\n#sai-input-wrapper div {\n display: block;\n min-height: 24px;\n color: #fff;\n}\n\n#sai-input-wrapper.is-idle {\n background-color: #9a8e81;\n animation: none;\n}\n\n/*.light #sai-input-wrapper.is-idle {\n background-color: #7f7a89;\n}*/\n\n#sai-input-wrapper.is-idle #sai-speech-button {\n right: 50%;\n margin-right: -13px;\n width: 24px;\n height: 24px;\n top: 12px;\n}\n\n#sai-input-wrapper.is-idle #sai-speech-button svg {\n width: 24px;\n height: 24px;\n}\n\n#sai-speech-button {\n position: absolute;\n top: 10px;\n right: 12px;\n width: 18px;\n transition: 0.5s;\n right: 10px;\n user-select: none;\n}\n\n#sai-speech-button svg {\n width: 18px;\n height: 18px;\n}\n\n#sai-input-wrapper.is-idle #sai-cancel-msg {\n visibility: hidden;\n opacity: 0;\n}\n\n#sai-button-wrapper {\n display: flex;\n justify-content: space-between;\n flex: 1;\n padding: 10px 15px;\n background: #eeeeee;\n margin-left: 15px;\n border-radius: 5px;\n z-index: 10;\n}\n\n.dark #sai-button-wrapper {\n background: #eeeeee4a;\n}\n\n#sai-cancel-msg {\n font-size: 8px;\n color: #fff;\n position: absolute;\n bottom: -7px;\n right: 12px;\n transition: 0.2s;\n user-select: none;\n visibility: visible;\n opacity: 1;\n}\n\n#sai-speech-button path {\n fill: #fff;\n}\n\n#sai-lang-selector-wrapper {\n display: flex;\n align-items: center;\n}\n\n#sai-no-voices {\n font-size: 12px;\n cursor: pointer;\n min-width: 75px;\n text-decoration: underline;\n color: #1abc9c;\n}\n\n#sai-no-voices:hover {\n opacity: 0.5;\n}\n\n#sai-lang-selector {\n font-size: 12px;\n height: 25px;\n padding: 0 10px;\n user-select: none;\n height: 30px;\n}\n\n#sai-lang-selector.sai-hide {\n display: none;\n}\n\n.dark #sai-lang-selector {\n color: #000 !important;\n}\n\n#sai-settings-button {\n background-color: #1a82bc;\n padding: 3px 4px;\n border-radius: 5px;\n}\n\n#sai-settings-button svg {\n width: 24px;\n height: 22px;\n margin-top: 1px;\n}\n\n#sai-skip-read-aloud.sai-active:hover,\n#sai-disable-read-aloud:hover,\n#sai-settings-button:hover {\n opacity: 0.8;\n cursor: pointer;\n}\n\n\n#sai-disable-read-aloud {\n background-color: #1abc9c;\n padding: 3px 4px;\n border-radius: 5px;\n margin-left: 10px;\n margin-right: 10px;\n position: relative;\n}\n\n#sai-disable-read-aloud.disabled {\n background-color: #cb4b4b;\n}\n\n#sai-disable-read-aloud.disabled:before {\n content: "";\n width: 2px;\n height: 25px;\n background-color: #fff;\n position: absolute;\n transform: rotate(45deg);\n left: 13px;\n}\n\n#sai-disable-read-aloud svg {\n fill: rgba(0,0,0,0.0);\n width: 24px;\n}\n\n#sai-skip-read-aloud {\n background-color: #969696;\n padding: 3px 4px;\n border-radius: 5px;\n margin-left: 10px;\n position: relative;\n}\n\n#sai-skip-read-aloud.sai-active {\n animation-name: yellow-pulsating-color;\n animation-duration: 2s;\n animation-iteration-count: infinite;\n background-color: #daa266;\n}\n\n#sai-skip-read-aloud svg {\n fill: #fff;\n height: 16px;\n width: 24px;\n margin-top: 6px;\n}\n\n@media only screen and (max-width:450px) {\n #sai-skip-read-aloud {\n display: none;\n }\n\n .sai-compact #sai-skip-read-aloud {\n display: block;\n }\n}\n\n@keyframes red-pulsating-color {\n 0% {\n background-color: #e02d2d;\n }\n 50% {\n background-color: #ef8585;\n }\n 100 {\n background-color: #e02d2d;\n }\n}\n\n@keyframes yellow-pulsating-color {\n 0% {\n background-color: #daa266;\n }\n 50% {\n background-color: #c78d4f;\n }\n 100 {\n background-color: #daa266;\n }\n}\n\ndiv.px-3.pt-2.pb-3.text-center.text-xs {\n padding: 6px;\n font-size: 0.6rem;\n}\n\n#sai-error-message {\n position: fixed;\n top: 0;\n right: 0;\n width: 200px;\n min-height: 100px;\n background-color: #cb4b4b;\n padding: 15px;\n box-shadow: rgb(0 0 0 / 21%) 0px 0px 10px 2px;\n color: #fff;\n font-weight: bold;\n font-size: 12px;\n}\n\n\n/* ==== SETTINGS ====== */\n\n#sai-settings-view {\n position: fixed;\n right: 0;\n top: 0;\n width: 100%;\n background-color: rgb(30 30 30 / 90%);\n height: 100vh;\n padding: 25px;\n z-index: 100000;\n}\n\n#sai-settings-view.sai-hide {\n display: none;\n}\n\n#sai-settings-view-inner {\n max-width: 700px;\n margin: 0 auto;\n display: flex;\n justify-content: space-between;\n}\n\n.sai-settings-col {\n width: 45%;\n}\n\n#sai-settings-header {\n display: flex;\n justify-content: space-between;\n align-items: flex-start;\n max-width: 700px;\n margin: 0 auto;\n border-bottom: 1px solid #777;\n margin-bottom: 25px;\n padding-bottom: 10px;\n}\n\n#sai-settings-view a {\n color: #1abc9c;\n text-decoration: none;\n font-weight: bold;\n}\n\n.sai-button {\n all: unset;\n background-color: #1abc9c;\n color: #fff;\n padding: 10px 15px;\n font-weight: bold;\n border-radius: 5px;\n font-size: 14px;\n color: #fff !important;\n cursor: pointer;\n line-height: 1.6;\n}\n\n.sai-button:hover {\n opacity: 0.8;\n}\n\n\n#sai-settings-view h3,\n#sai-settings-view h4,\n#sai-settings-view p {\n color: #fff;\n margin-bottom: 25px;\n}\n\n#sai-settings-view li {\n color: #fff;\n}\n\n#sai-settings-view h3 {\n font-size: 20px;\n}\n\n#sai-settings-view h4 {\n font-size: 17px;\n font-weight:bold;\n margin-bottom: 15px;\n}\n\n\n.sai-settings-section {\n margin-top: 35px;\n padding-top: 25px;\n border-top: 1px solid #777;\n}\n\n#sai-settings-view li strong {\n color: #ffca92;\n}\n\n#sai-settings-view ul {\n padding-left: 0;\n margin: 0;\n list-style: none;\n}\n\n#sai-settings-view li {\n margin-top: 10px;\n}\n\n#sai-settings-read-aloud-header {\n\n}\n\n#sai-settings-voice-link {\n display: inline-block;\n margin-top: 7px;\n font-size: 12px;\n}\n\n.sai-slidecontainer {\n width: 100%;\n}\n\n.sai-slider {\n -webkit-appearance: none;\n width: 100%;\n height: 15px;\n border-radius: 5px;\n background: #d3d3d3;\n outline: none;\n opacity: 0.7;\n -webkit-transition: 0.2s;\n transition: opacity 0.2s;\n}\n\n.sai-slider:hover {\n opacity: 1;\n}\n\n.sai-slider::-webkit-slider-thumb {\n -webkit-appearance: none;\n appearance: none;\n width: 25px;\n height: 25px;\n border-radius: 50%;\n background: #1abc9c;\n cursor: pointer;\n}\n\n.sai-link-talkio {\n color: #ac99ff !important;\n}\n\n@media only screen and (max-height: 720px) {\n #sai-settings-header {\n margin-bottom: 15px;\n padding-bottom: 0;\n }\n\n #sai-settings-view {\n font-size: 12px;\n overflow-y: auto;\n }\n\n #sai-settings-view h4 {\n font-size: 16px;\n }\n\n .sai-settings-section {\n margin-top: 20px;\n padding-top: 10px;\n }\n}\n\n/* ======== REPEAT BUTTON ======= */\n.sai-repeat-button {\n border-radius: 5px;\n width: 22px;\n height: 22px;\n cursor: pointer;\n position: relative;\n}\n\n.sai-repeat-button.sai-disabled {\n display: none;\n}\n\n.sai-repeat-button svg {\n width: 18px;\n height: 18px;\n margin-top: 2px;\n margin-left: 2px;\n}\n\n.sai-repeat-button path {\n fill: #acacbe !important;\n}\n\n.sai-repeat-button:hover {\n background: #ececf1;\n}\n\n.sai-repeat-button:hover path {\n fill: #40414f !important;\n}\n\n.dark .sai-repeat-button:hover {\n background: #40414f;\n}\n\n.dark .sai-repeat-button:hover path {\n fill: #fff !important;\n}\n\n\n/* ======== HIDE SAI ======= */\n.sai-hidden #sai-input-wrapper,\n.sai-hidden #sai-lang-selector-wrapper,\n.sai-hidden #sai-skip-read-aloud,\n.sai-hidden #sai-disable-read-aloud {\n display: none;\n}\n\n.sai-hidden #sai-button-wrapper {\n background: transparent;\n padding: 0;\n}\n\n.sai-hidden #sai-settings-button {\n border-radius: 5px;\n position: fixed;\n top: 7px;\n right: 45px;\n z-index: 10000;\n}\n\n@media only screen and (min-width: 768px) {\n .sai-hidden #sai-settings-button {\n top: 20px;\n right: 20px\n }\n}\n\n@media only screen and (max-width: 768px) {\n form > div.relative.flex.h-full {\n flex-direction: column;\n }\n\n #sai-input-wrapper {\n height: 50px;\n }\n}\n\n/* ======== SAI COMPACT ======= */\n.sai-compact #sai-root {\n height: 0;\n margin: 0;\n position: relative;\n}\n\n.sai-compact #sai-input-wrapper{\n position: absolute;\n width: 30px;\n height: 30px;\n right: 10px;\n top: 8px;\n border: none;\n z-index: 10;\n}\n\n.sai-compact #sai-input-wrapper.is-idle {\n background: none;\n border: none;\n box-shadow: none;\n opacity: 0.5;\n}\n\n.sai-compact .sai-input {\n display: none !important;\n}\n\n.sai-compact #sai-speech-button {\n width: 20px !important;\n height: 20px !important;\n top: 4px !important;\n right: 0 !important;\n margin-right: 4px !important;\n}\n\n.sai-compact #sai-speech-button svg {\n width: 20px !important;\n height: 20px !important;\n}\n\n.sai-compact #sai-input-wrapper.is-idle #sai-speech-button svg path {\n fill: #999;\n}\n\n.sai-compact #sai-cancel-msg {\n display: none;\n}\n\n.sai-compact #sai-button-wrapper {\n position: absolute;\n bottom: 15px;\n right: 0;\n padding: 5px 7px;\n}\n\n.sai-compact #sai-lang-selector {\n font-size: 10px !important;\n height: 25px;\n}\n\n.sai-compact #sai-settings-button svg,\n.sai-compact #sai-disable-read-aloud svg{\n width: 20px !important;\n height: 20px !important;\n margin-top: 0px !important;\n}\n\n.sai-compact #sai-skip-read-aloud svg {\n width: 20px !important;\n height: 13px !important;\n margin-top: 5px !important;\n}\n\n.sai-compact #sai-disable-read-aloud.disabled:before {\n left: 11px;\n bottom: 1px;\n}\n\n.sai-compact textarea {\n padding-right: 4rem !important;\n}\n\n.sai-compact textarea + button {\n margin-right: 35px;\n}\n\n@media only screen and (max-width: 900px) {\n .sai-compact .flex.ml-1.gap-0.justify-center{\n position: static;\n justify-content: flex-start !important;\n }\n}\n\n@media only screen and (max-width: 768px) {\n .sai-compact .w-full.h-32.flex-shrink-0 {\n margin-top: 25px;\n }\n\n .sai-compact .flex.ml-1.gap-0.justify-center{\n position: absolute;\n bottom: 62px;\n height: 30px;\n }\n}\n\n@media only screen and (min-width: 768px) {\n .sai-compact #sai-input-wrapper {\n top: 12px;\n }\n\n .sai-compact #sai-button-wrapper {\n bottom: 10px;\n }\n\n .sai-compact .flex.ml-1.gap-0.justify-center{\n position: absolute;\n top: -46px;\n max-height: 36px;\n }\n}\n\n', document.body.appendChild(e), function (e) { e.info = "info", e.warning = "warning", e.error = "error", e.verbose = "verbose", e.success = "success" }(n || (n = {})); class t { constructor(e = !0) { this.logToConsole = e, window.addEventListener("sai-print-logs", (() => { console.log("All logs:"), console.log(t.allLogs) })) } static info(e, t) { this.instance.write(e, n.info, t) } static success(e, t) { this.instance.write(e, n.success, t) } static warn(e, t) { this.instance.write(e, n.warning, t) } static error(e, t) { this.instance.write(e, n.error, t) } static verbose(e, i) { this.instance.logToConsole && t.allLogs.push([Date.now(), n.verbose, e, i]) } static setup() { if (!t.instance) { const e = "true" === window.localStorage.getItem("sai-log"); this.instance = new t(e) } return t.instance } write(e, n, i) { if (this.logToConsole) { const s = `color: ${this.getConsoleColor(n)}`; i ? console.log(`%c[${n}] ${e}`, s, i) : console.log(`%c[${n}] ${e}`, s), t.allLogs.push([Date.now(), n, e, i]) } } getConsoleColor(e) { return e === n.info ? "#2e99d9" : e === n.warning ? "#ffbb00" : e === n.success ? "#1abc9c" : "#b91e1e" } } t.allLogs = []; class i { constructor(e) { this.element = e, this.isVisible = !1 } write(e, n = 3e3) { this.isVisible && clearTimeout(this.timer), this.element.innerHTML = e, this.setVisible(!0), this.timer = setTimeout((() => { this.setVisible(!1), this.element.innerHTML = "" }), n) } setVisible(e) { this.element.style.display = e ? "block" : "none", this.isVisible = e } } function s(e, n) { return e === n || ("zh-CN" === e && "cmn-Hans-CN" === n || ("zh-TW" === e && "cmn-Hant-TW" === n || "zh-HK" === e && "yue-Hant-HK" === n)) } const a = [ ["English (US)", "en-US"], ["English (UK)", "en-GB"], ["English (AU)", "en-AU"], ["English (CA)", "en-CA"], ["English (IN)", "en-IN"], ["English (NZ)", "en-NZ"], ["普通话 (中国大陆)", "cmn-Hans-CN"], ["中文 (台灣)", "cmn-Hant-TW"], ["粵語 (香港)", "yue-Hant-HK"], ["Afrikaans", "af-ZA"], ["Bahasa Indonesia", "id-ID"], ["Bahasa Melayu", "ms-MY"], ["Català", "ca-ES"], ["Čeština", "cs-CZ"], ["Dansk", "da-DK"], ["Deutsch", "de-DE"], ["Español (ES)", "es-ES"], ["Español (MX)", "es-MX"], ["Español (AR)", "es-AR"], ["Español (CO)", "es-CO"], ["Español (PE)", "es-PE"], ["Español (VE)", "es-VE"], ["Euskara", "eu-ES"], ["Français", "fr-FR"], ["Galego", "gl-ES"], ["Hrvatski", "hr_HR"], ["IsiZulu", "zu-ZA"], ["Íslenska", "is-IS"], ["Italiano", "it-IT"], ["Magyar", "hu-HU"], ["Nederlands", "nl-NL"], ["Norsk bokmål", "nb-NO"], ["Polski", "pl-PL"], ["Português (PT)", "pt-PT"], ["Português (BR)", "pt-BR"], ["Română", "ro-RO"], ["Slovenčina", "sk-SK"], ["Suomi", "fi-FI"], ["Svenska", "sv-SE"], ["Türkçe", "tr-TR"], ["български", "bg-BG"], ["日本語", "ja-JP"], ["한국어", "ko-KR"], ["Pусский", "ru-RU"], ["Српски", "sr-RS"] ]; let o = []; async function r() { if (o.length > 0) return o; const e = await new Promise((e => { window.speechSynthesis.onvoiceschanged = () => { const n = window.speechSynthesis.getVoices(); e(n) } })); return a.forEach((n => { e.some((e => s(e.lang, n[1]))) ? o.push(n) : t.warn(`${n[0]} not supported. Removed from selector.`) })), o } class l { constructor(e, n) { this.selectionCb = e, this.selected = n, this.storageKey = "sai-language", this.setDefaultFromStorage(), this.element = document.createElement("div"), this.selector = document.createElement("select"), this.element.id = "sai-lang-selector-wrapper", this.selector.id = "sai-lang-selector", r().then((e => { if (0 === e.length) { this.selector.classList.add("sai-hide"); const e = document.createElement("div"); return e.id = "sai-no-voices", e.innerHTML = "Install voices", void this.element.appendChild(e) } e.forEach((([e, n]) => { const t = document.createElement("option"); t.innerText = e, t.value = n, n === this.selected && (t.selected = !0), this.selector.appendChild(t) })), this.element.appendChild(this.selector), this.selector.onchange = e => { const n = e.target; this.selectLanguage(n.value) } })) } selectLanguage(e) { window.localStorage.setItem(this.storageKey, e), this.selectionCb(e) } setDefaultFromStorage() { let e = window.localStorage.getItem(this.storageKey); e && (this.selected = e, this.selectLanguage(e)) } } class c { constructor(e, n, i) { this.lang = e, this.waitForContent = i, this.lastText = "", this.lastRead = Date.now(), this.lastUtter = Date.now(), this.lastUtterCharCount = 0, this.lastTimeout = 0, this.lastTimeSinceLastUtter = 0, this.synth = window.speechSynthesis, this.queue = [], this.enabled = !0, this.storageKey = "sai-read-aloud", this.queueIdle = !0, this.disableButton = document.createElement("div"), this.disableButton.innerHTML = '\n\n\n\n\n\t\n\t\n\t\n\t\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n', this.disableButton.id = "sai-disable-read-aloud", this.disableButton.title = "Toggle read aloud", this.skipButton = document.createElement("div"), this.skipButton.innerHTML = '\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n', this.skipButton.id = "sai-skip-read-aloud", this.skipButton.title = "Skip read aloud", window.speechSynthesis.cancel(), this.disableButton.addEventListener("click", (() => { this.enabled ? this.disableReadAloud() : this.enableReadAloud() })), this.skipButton.onclick = () => { this.skipReading() }, this.setReadAloudFromStorage(), t.info(`reInit ${n}, lastTextLength: ${this.lastText.length}`), n && this.reset() } async runQueue() { if (t.info(`Queue is idle: ${this.queueIdle}`), this.queue.length > 0 && this.queueIdle) { this.skipButton.classList.add("sai-active"), this.queueIdle = !1; const e = this.queue.shift(); await this.readAloud(e), this.queueIdle = !0, this.skipButton.classList.remove("sai-active"), this.queue.length > 0 && this.runQueue() } } observerCallback(e) { const n = this.getText(); if (0 === n.length) t.info("No text, reset"), this.reset(); else if (this.waitForContent) return t.info("Wait for content"), this.lastText = n, void(this.waitForContent = !1); const i = n.replace(this.lastText.trim(), "").trim(), s = i[i.length - 1], a = this.lastRead + 1e4 < Date.now(); if (i.length > 0 && ("." === s || "?" === s || "!" === s || ":" === s || "。" === s || a)) { a && (t.warn(`Long time since last read. Queue length: ${this.queue.length}`), this.queueIdle = !0), t.info(`Push to queue: ${i}`); i.split(".").filter((e => e.length > 0)).forEach((e => { this.queue.push(e) })), this.runQueue(), this.lastRead = Date.now(), this.lastText = n } } setLang(e) { this.lang = e } skipReading() { this.synth.cancel(), this.queue = [], this.queueIdle = !0; const e = document.querySelectorAll(".text-base"); for (var n = 0; n < e.length; n++) e[n]?.classList.add("sai-skip") } repeat(e) { this.synth.cancel(), this.queue = [], this.queueIdle = !0; const n = this.getText(e); t.info(`Repeat: ${n}`), this.queue.push(n), this.runQueue() } readAloud(e) { return new Promise(((n, i) => { if (!this.enabled) return t.info("Read aloud disabled"), void n(void 0); if (!e) return t.info("No text to read"), void n(void 0); if (!document.getElementById("sai-root")) return void n(void 0); let a = e.replace(/([0-9]+)\.(?=[0-9]+(?!\.))/g, "$1,"); this.synth = window.speechSynthesis; const o = new SpeechSynthesisUtterance(a), r = this.synth.getVoices().reverse().filter((e => s(e.lang, this.lang))), l = window.localStorage.getItem("sai-voice-preference" + this.lang), c = r.find((e => e.voiceURI === l)) ?? r[0]; if (!c) throw new Error(`unknown voice: ${c} lang: ${this.lang}`); o.volume = 1, o.voice = c; const d = window.localStorage.getItem("sai-voice-speed-v2"); d || t.error("No speed stored in storage"); const p = function (e) { switch (e) { case "1": return .1; case "2": return .2; case "3": return .3; case "4": return .4; case "5": return .5; case "6": return .6; case "7": return .7; case "8": return .8; case "9": return .9; case "10": default: return 1; case "11": return 1.1; case "12": return 1.13; case "13": return 1.15; case "14": return 1.17; case "15": return 1.2; case "16": return 1.25; case "17": return 1.3; case "18": return 1.35; case "19": return 1.4; case "20": return 1.45 } }(d); let h; o.rate = p, t.success(`Voice name: ${c.name}, lang: ${c.lang}, rate: ${p}: ${a}`); const u = () => { const [e, n] = function (e, n, i, s, a, o) { const r = Date.now() - i, l = function (e, n, t) { let i = 100; return "zh-CN" !== e && "zh-TW" !== e && "zh-HK" !== e || (i = 240), "zh-TW" === e && (i = 300), "ja-JP" === e && (i = 260), "ko-KR" === e && (i = 240), 7e3 + n * i * (1 / t) }(e, a, n); if (t.warn(`[resumeInfinity] Time since last utter: ${r.toFixed(1)}. Timeout: ${l.toFixed(1)}. Last char count: ${a}`), window.navigator.userAgent.search("Mac") > -1 && 0 === r && o > 0) { const e = s - o, n = e / s * 100; t.warn(`Last timeout safety gap: ${e.toFixed(1)}ms. ${n.toFixed(1)}%`), n < 25 && t.error(`________Safety gap ${n.toFixed(1)}% too low!________`) } return r > l ? (t.error(`No utter timeout ${l.toFixed(1)} - cancel.`), window.speechSynthesis.cancel(), setTimeout((() => { window.speechSynthesis.resume() }), 50), [0, 0]) : [l, r] }(c.lang, p, this.lastUtter, this.lastTimeout, this.lastUtterCharCount, this.lastTimeSinceLastUtter); this.lastTimeout = e, this.lastTimeSinceLastUtter = n, window.speechSynthesis.pause(), window.speechSynthesis.resume(), h = setTimeout(u, 7e3) }; o.addEventListener("error", (e => { t.error(`Read aloud error ${e.error}`, e), n(void 0), clearTimeout(h) })), o.addEventListener("start", (() => { t.info(`Speech has started. Volume: ${o.volume}`), this.lastUtter = Date.now(), u() })), o.addEventListener("end", (function (e) { t.info("Speech has ended"), n(void 0), clearTimeout(h) })), o.addEventListener("pause", (function (e) { t.verbose("Speech has paused", e) })), o.addEventListener("resume", (function (e) { t.verbose("Speech has resumed", e) })), o.addEventListener("boundary", (function (e) { t.verbose(`Speech reached boundary. CharIndex: ${e.charIndex}`, e) })), o.addEventListener("mark", (function (e) { t.info("Speech reached mark", e) })), this.synth.speak(o), this.lastUtterCharCount = a.length })) } enableReadAloud() { this.enabled = !0, this.disableButton.classList.remove("disabled"), this.updateStorage(), document.querySelectorAll(".sai-repeat-button").forEach((e => { e.classList.remove("sai-disabled") })) } disableReadAloud() { this.queue = [], this.queueIdle = !0, this.synth.cancel(), this.disableButton.classList.add("disabled"), this.enabled = !1, this.updateStorage(), document.querySelectorAll(".sai-repeat-button").forEach((e => { e.classList.add("sai-disabled") })) } updateStorage() { window.localStorage.setItem(this.storageKey, this.enabled.toString()) } setReadAloudFromStorage() { const e = window.localStorage.getItem(this.storageKey); e && (this.enabled = "true" === e, this.enabled ? this.enableReadAloud() : this.disableReadAloud()) } getText(e) { const n = document.querySelectorAll(".text-base:not(.sai-skip) .markdown"), t = (e || n[n.length - 1])?.children ?? []; let i = ""; for (const e of t) "PRE" !== e.nodeName && (i += e.textContent); return i = i.replace(/`/g, "").replace(/\*/g, "").replace(/\"/g, "").replace(/\\n/g, "").replace(/\\t/g, "").replace(/\\b/g, "").replace(/(/g, " (").replace(/)/g, ") ").replace(/?/g, "? ").replace(/:/g, ": ").replace(/!/g, "! ").replace(/。/g, ". "), i } reset() { t.warn("RESET read aloud queue"), this.queue = [], this.lastRead = Date.now() } } class d { constructor(e, n, i) { this.lang = e, this.errorMessage = n, this.transcript = "", this.recognition = new webkitSpeechRecognition, this.isRecording = !1, this.recognition.continuous = !0, this.recognition.interimResults = !0, this.recognition.onstart = () => {}, this.recognition.onresult = e => { let n = ""; for (let t = e.resultIndex; t < e.results.length; ++t) e.results[t].isFinal ? this.isRecording && (this.transcript += e.results[t][0].transcript, i(this.transcript)) : n += e.results[t][0].transcript; this.isRecording && i(this.transcript + n) }, this.recognition.onerror = e => { let n = e.error; "not-allowed" === e.error && (n = "The webpage is not allowed to access your microphone"), "no-speech" === e.error && (n = "No sound from the microphone"); let i = `\n \n Error from Voice Control:\n
\n ${n}\n

\n \n See voicecontrol.chat/support for help\n \n
\n `; this.errorMessage.write(i, 8e3), t.error(`recognition.onerror ${e.error}`) }, this.recognition.onend = () => { t.info("Ended"), this.endCallback?.() } } start(e) { t.info("Start"), this.endCallback = e, this.recognition.lang = this.lang, this.recognition.start(), this.isRecording = !0 } stop() { t.info(`Stop: ${this.transcript}`), this.isRecording = !1, this.recognition.stop(), this.endCallback = void 0 } reset() { this.isRecording = !1, this.transcript = "" } setLang(e) { this.lang = e } } class p { constructor(e) { this.readAloud = e, this.showCompactUi = "true" === window.localStorage.getItem("sai-compact-ui"), this.appIsHidden = "true" === window.localStorage.getItem('"sai-hidden"'), this.settingsView = document.createElement("div"), this.settingsView.innerHTML = `\n
\n

Voice Control for ChatGPT

\n \n
\n
\n
\n
\n

Read aloud speed:

\n
\n \n
\n
\n\n
\n

Voice preference

\n
\n \n Install more voices\n \n
\n\n
\n

Display settings

\n

\n \n

\n \n
\n\n\n
\n

Need help or have a suggestion?

\n

\n If you have trouble loading voices or need help troubleshooting please\n \n see the FAQ.\n \n

\n\n

\n If you have suggestions on how to improve the extension please share your ideas\n \n here.\n \n

\n
\n
\n
\n

Keyboard shortcuts

\n\n \n\n
\n

Upgrade your language learning experience with Talkio AI,\n the premium version of this extension designed specifically for language learners.

\n
\n\n
\n

\n The extension is created by Theis Frøhlich\n
\n Please leave a review\n if you like this extension.\n

\n
\n
\n
`, this.settingsView.id = "sai-settings-view", this.settingsView.classList.add("sai-hide") } setupListeners() { const e = document.getElementById("sai-popup-range-slider"), n = document.getElementById("sai-read-aloud-speed"), i = document.getElementById("sai-close-settings"), s = document.getElementById("sai-display-toggle"), a = document.getElementById("sai-ui-toggle"); if (!(e && n && i && s && a)) return void t.warn("settings element missing"); i.onclick = () => { this.settingsView.classList.add("sai-hide") }, s.onclick = () => { document.body.classList.toggle("sai-hidden"); const e = window.localStorage.getItem('"sai-hidden"'); e && "true" === e ? (window.localStorage.setItem('"sai-hidden"', "false"), this.appIsHidden = !1, s.innerText = "Hide Voice Control") : (window.localStorage.setItem('"sai-hidden"', "true"), this.appIsHidden = !0, this.readAloud.disableReadAloud(), s.innerText = "Show Voice Control") }, a.onclick = () => { document.body.classList.toggle("sai-compact"); const e = window.localStorage.getItem("sai-compact-ui"); e && "true" === e ? (window.localStorage.setItem("sai-compact-ui", "false"), this.showCompactUi = !1, a.innerText = "Compact interface") : (window.localStorage.setItem("sai-compact-ui", "true"), this.showCompactUi = !0, a.innerText = "Classic interface"); document.getElementById("sai-root")?.remove() }; const o = t => { n.innerHTML = this.labelFromSpeedValue(t); e.value = t, window.localStorage.setItem("sai-voice-speed-v2", t) }; e.oninput = e => { const n = e.target; o(n.value) }; const r = window.localStorage.getItem("sai-voice-speed-v2"); r && o(r) } createVoiceSelector() { const e = window.localStorage.getItem("sai-language") ?? "en-US", n = window.speechSynthesis.getVoices().filter((n => s(n.lang, e))).reverse(), t = window.localStorage.getItem("sai-voice-preference" + e), i = document.createElement("select"); i.id = "sai-voice-selector", i.style.color = "black", i.style.width = "100%", n.forEach((e => { const n = document.createElement("option"); n.innerText = e.name, n.value = e.voiceURI, t === n.value && (n.selected = !0), i.appendChild(n) })), i.onchange = n => { const t = n.target; window.localStorage.setItem("sai-voice-preference" + e, t.value) }; const a = document.getElementById("sai-voice-settings"); a && (a.innerHTML = "", a.appendChild(i)) } labelFromSpeedValue(e) { return e } } class h { constructor(e) { const n = "true" === window.localStorage.getItem("sai-read-aloud"); this.element = document.createElement("div"), this.element.innerHTML = '\n\n\n\n\n', this.element.classList.add("sai-repeat-button"), n || this.element.classList.add("sai-disabled"), this.element.onclick = () => { const n = this.element.closest(".text-base")?.querySelector(".markdown"); n ? e.repeat(n) : t.warn("Could not find text element to repeat") } } } class u { constructor(e) { this.readAloud = e } injectRepeatButtons() { document.querySelectorAll(".text-base .text-gray-400.flex.self-end.justify-center.mt-2.gap-2.visible .flex.gap-1").forEach((e => { if (e.querySelectorAll(".sai-repeat-button").length > 0) return; const n = new h(this.readAloud); e.appendChild(n.element) })) } } const g = a[0][1]; class m { constructor(e = !1, n = !1) { this.isRecording = !1, this.language = g, this.spaceIsDown = !1, this.isCompact = "true" === window.localStorage.getItem("sai-compact-ui"), t.info("Init app"); const s = document.querySelector("textarea"), a = s?.parentElement, o = a?.querySelector("button"); if (!s || !a || !o) throw new Error("Missing elements"); this.chatGptInput = s, this.chatGptInputParent = a, this.chatGptSubmitButton = o, this.saiRoot = document.createElement("div"), this.saiRoot.id = "sai-root", this.saiInput = document.createElement("div"), this.saiInput.className = s.classList.value + " sai-input", this.saiInputWrapper = document.createElement("div"), this.saiInputWrapper.id = "sai-input-wrapper", this.saiInputWrapper.className = this.chatGptInputParent.classList.value + " is-idle", this.saiInputWrapper.appendChild(this.saiInput), this.saiRoot.appendChild(this.saiInputWrapper), this.isCompact ? this.chatGptInputParent.before(this.saiRoot) : this.chatGptInputParent.after(this.saiRoot), this.saiCancelMsg = document.createElement("div"), this.saiCancelMsg.id = "sai-cancel-msg", this.saiCancelMsg.innerHTML = "press esc to cancel", this.saiInputWrapper.appendChild(this.saiCancelMsg), this.saiRecordButton = document.createElement("div"), this.saiRecordButton.id = "sai-speech-button", this.saiRecordButton.innerHTML = '', this.saiInputWrapper.appendChild(this.saiRecordButton), this.saiButtonWrapper = document.createElement("div"), this.saiButtonWrapper.id = "sai-button-wrapper", this.saiSettingsButton = document.createElement("div"), this.saiSettingsButton.id = "sai-settings-button", this.saiSettingsButton.innerHTML = '\n\n\n\n /svg/ic-settings\n Created with Sketch.\n \n\n\n \n \n \n\n\n \n \n', this.saiSettingsButton.onclick = () => { document.getElementById("sai-settings-view")?.classList.remove("sai-hide"), this.settings.createVoiceSelector() }, this.saiErrorMessage = document.createElement("div"), this.saiErrorMessage.id = "sai-error-message", this.saiErrorMessage.innerHTML = "error", this.saiErrorMessage.style.display = "none", this.saiRoot.append(this.saiErrorMessage), this.errorMessage = new i(this.saiErrorMessage), this.speech = new d(this.language, this.errorMessage, this.speechCallback.bind(this)), this.readAloud = new c(this.language, e, n), this.settings = new p(this.readAloud), this.speechHandlers(); const r = new l(this.setLanguage.bind(this), g); this.saiButtonWrapper.appendChild(r.element), this.saiRoot.appendChild(this.saiButtonWrapper), this.saiButtonWrapper.appendChild(this.readAloud.skipButton), this.saiButtonWrapper.appendChild(this.readAloud.disableButton), this.saiButtonWrapper.appendChild(this.saiSettingsButton), this.saiRoot.appendChild(this.settings.settingsView), this.settings.setupListeners(), this.repeatHandler = new u(this.readAloud); document.querySelectorAll("#sai-root").length > 1 && this.errorMessage.write("\n Looks like Voice Control for ChatGPT is installed twice.\n Please go to chrome://extensions and disable one of the installations\n \n ", 7e3) } keyDownHandler(e) { const n = e.target; if ("textarea" === n?.localName || "Space" !== e.code || this.spaceIsDown || (this.holdSpaceTimer = setTimeout((() => { t.info("Space start"), this.startRecording(), this.speech.start((() => { this.stopRecording() })) }), 250), this.spaceIsDown = !0), "textarea" !== n?.localName && "Space" === e.code && this.isRecording && this.isCompact && this.appToIdle(), "textarea" === n?.localName || "Escape" !== e.code && "KeyQ" !== e.code || !this.isRecording || (t.info(`Pressed ${e.code}`), this.appToIdle()), ("Escape" === e.code || "KeyQ" === e.code) && !this.isRecording) { t.info(`Pressed ${e.code}. Close settings`); document.getElementById("sai-settings-view")?.classList.add("sai-hide") } "KeyE" === e.code && this.isRecording && (t.info("Pressed KeyE"), this.chatGptInput.value = this.saiInput.innerText, this.appToIdle()), "textarea" !== n?.localName && "Enter" === e.code && this.isRecording && (t.info("Enter stop"), this.submitToChatGPT(this.chatGptInput.value), this.appToIdle()) } keyUpHandler(e) { this.spaceIsDown && "Space" === e.code && (this.isCompact || (t.info("Space stop"), this.stopRecording())) } onSubmit() { t.info("on Submit"), this.appToIdle() } adjustCompactIconPos() { if (!this.isCompact) return; const e = this.chatGptInput.offsetHeight, n = document.querySelector("textarea + button.absolute.p-1.rounded-md"); n && (n.style.marginRight = e > 30 ? "0" : "35px") } speechHandlers() { this.spaceIsDown = !1, this.saiInputWrapper.onclick = () => { this.isRecording ? (this.speech.stop(), this.stopRecording()) : (this.startRecording(), this.speech.start((() => { this.stopRecording() }))) } } startRecording() { this.isRecording = !0, this.saiInputWrapper.classList.remove("is-idle") } stopRecording() { if (this.isCompact) this.chatGptInput.dispatchEvent(new Event("input", { bubbles: !0 })); else { const e = this.saiInput.innerText; this.submitToChatGPT(e) } this.appToIdle() } submitToChatGPT(e) { e.length > 0 && (this.chatGptInput.value = e, this.chatGptInput.dispatchEvent(new Event("input", { bubbles: !0 })), this.chatGptSubmitButton.click()) } appToIdle() { this.speech.stop(), this.speech.reset(), this.isRecording = !1, this.saiInput.innerText = "", this.saiInputWrapper.classList.add("is-idle"), this.spaceIsDown = !1, clearTimeout(this.holdSpaceTimer) } speechCallback(e) { this.isCompact ? e.length > 0 && (this.chatGptInput.value = e, this.chatGptInput.dispatchEvent(new Event("input", { bubbles: !0 }))) : this.saiInput.innerText = e } setLanguage(e) { this.language = e, this.readAloud.setLang(e), this.speech.setLang(e) } } t.setup(); window.localStorage.getItem("sai-voice-speed-v2") || window.localStorage.setItem("sai-voice-speed-v2", "10"); "true" === window.localStorage.getItem('"sai-hidden"') && document.body.classList.add("sai-hidden"); function v() { const e = () => /^\/c\/(.*)$/.test(window.location.pathname); let n = new m(!1, e()), i = !1; const s = e => { n.keyDownHandler(e) }, a = e => { n.keyUpHandler(e) }, o = e => { "Enter" === e.code && n.onSubmit() }, r = () => { n.onSubmit() }; document.addEventListener("keydown", s), document.addEventListener("keyup", a); const l = "form button.absolute.p-1.rounded-md.text-gray-500"; function c(c) { const d = document.getElementById("sai-root"), p = document.querySelector("textarea"); n?.adjustCompactIconPos(), n?.repeatHandler?.injectRepeatButtons(), i && p && (t.info("Re-init app"), d && d.remove(), n = new m(!0, e()), i = !1, document.addEventListener("keydown", s), document.addEventListener("keyup", a), document.querySelector("textarea")?.addEventListener("keyup", o), document.querySelector(l)?.addEventListener("click", r)), d && p || (t.warn("App removed"), d && d.remove(), n.readAloud.reset(), document.removeEventListener("keydown", s), document.removeEventListener("keyup", a), document.querySelector("textarea")?.removeEventListener("keyup", o), document.querySelector(l)?.removeEventListener("click", r), i = !0), d && n.readAloud.observerCallback(c) } document.querySelector("textarea")?.addEventListener("keyup", o), document.querySelector(l)?.addEventListener("click", r); new MutationObserver(c).observe(document.body, { childList: !0, subtree: !0, characterData: !0 }), setInterval((() => { c([]) }), 3500) } "true" === window.localStorage.getItem("sai-compact-ui") && document.body.classList.add("sai-compact"), // chrome.runtime.onMessage.addListener(((e,n,t)=>("sai-on-chatgpt-message" === e.key && t({ // value: "yes-we-are-here" // }), // !0))); document.querySelector("textarea") ? v() : setTimeout((() => { v() }), 2e3); // window.top.document.getElementById("on-app")?.classList.remove("invisible"); })();