// ==UserScript== // @name Voice Control for ChatGPT // @name:zh-CN Voice Control for ChatGPT // @namespace http://tampermonkey.net/ // @version 0.1.5 // @description Expands ChatGPT with voice control and read aloud. fork from https://chrome.google.com/webstore/detail/eollffkcakegifhacjnlnegohfdlidhn // @description:zh-cn 扩展 ChatGPT,支持语音输入和回答朗读。 // @author You // @match https://chatgpt.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com // @grant none // @run-at document-idle // @downloadURL https://update.greasyfork.icu/scripts/466857/Voice%20Control%20for%20ChatGPT.user.js // @updateURL https://update.greasyfork.icu/scripts/466857/Voice%20Control%20for%20ChatGPT.meta.js // ==/UserScript== (function () { 'use strict'; // Your code here... const style = document.createElement("style"); style.innerHTML = ` #sai-root { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; } #sai-input-wrapper { position: relative; cursor: pointer; background-color: #e02d2d; animation-name: red-pulsating-color; animation-duration: 2s; animation-iteration-count: infinite; max-width: 75%; } #sai-input-wrapper:hover { opacity: 0.7; } #sai-input-wrapper div.w-full { padding-right: 35px; } #sai-input-wrapper div { display: block; min-height: 24px; color: #fff; } #sai-input-wrapper.is-idle { background-color: #9a8e81; animation: none; } /*.light #sai-input-wrapper.is-idle { background-color: #7f7a89; }*/ #sai-input-wrapper.is-idle #sai-speech-button { right: 50%; margin-right: -13px; width: 24px; height: 24px; top: 4px; } #sai-input-wrapper.is-idle #sai-speech-button svg { width: 24px; height: 24px; } #sai-speech-button { position: absolute; top: 10px; right: 12px; width: 18px; transition: 0.5s; right: 10px; user-select: none; } #sai-speech-button svg { width: 18px; height: 18px; } #sai-input-wrapper.is-idle #sai-cancel-msg { visibility: hidden; opacity: 0; } #sai-button-wrapper { display: flex; justify-content: space-between; flex: 1; padding: 10px 15px; background: #eeeeee; margin-left: 15px; border-radius: 5px; z-index: 10; } .dark #sai-button-wrapper { background: #eeeeee4a; } #sai-cancel-msg { font-size: 8px; color: #fff; position: absolute; bottom: -7px; right: 12px; transition: 0.2s; user-select: none; visibility: visible; opacity: 1; } #sai-speech-button path { fill: #fff; } #sai-lang-selector-wrapper { display: flex; align-items: center; } #sai-no-voices { font-size: 12px; cursor: pointer; min-width: 75px; text-decoration: underline; color: #1abc9c; } #sai-no-voices:hover { opacity: 0.5; } #sai-lang-selector { font-size: 12px; height: 25px; padding: 0 10px; user-select: none; height: 30px; } #sai-lang-selector.sai-hide { display: none; } .dark #sai-lang-selector { color: #000 !important; } #sai-settings-button { background-color: #1a82bc; padding: 3px 4px; border-radius: 5px; } #sai-settings-button svg { width: 24px; height: 22px; margin-top: 1px; } #sai-skip-read-aloud.sai-active:hover, #sai-disable-read-aloud:hover, #sai-settings-button:hover { opacity: 0.8; cursor: pointer; } #sai-disable-read-aloud { background-color: #1abc9c; padding: 3px 4px; border-radius: 5px; margin-left: 10px; margin-right: 10px; position: relative; } #sai-disable-read-aloud.disabled { background-color: #cb4b4b; } #sai-disable-read-aloud.disabled:before { content: ""; width: 2px; height: 25px; background-color: #fff; position: absolute; transform: rotate(45deg); left: 13px; } #sai-disable-read-aloud svg { fill: rgba(0,0,0,0.0); width: 24px; } #sai-skip-read-aloud { background-color: #969696; padding: 3px 4px; border-radius: 5px; margin-left: 10px; position: relative; } #sai-skip-read-aloud.sai-active { animation-name: yellow-pulsating-color; animation-duration: 2s; animation-iteration-count: infinite; background-color: #daa266; } #sai-skip-read-aloud svg { fill: #fff; height: 16px; width: 24px; margin-top: 6px; } @media only screen and (max-width:450px) { #sai-skip-read-aloud { display: none; } .sai-compact #sai-skip-read-aloud { display: block; } } @keyframes red-pulsating-color { 0% { background-color: #e02d2d; } 50% { background-color: #ef8585; } 100 { background-color: #e02d2d; } } @keyframes yellow-pulsating-color { 0% { background-color: #daa266; } 50% { background-color: #c78d4f; } 100 { background-color: #daa266; } } div.px-3.pt-2.pb-3.text-center.text-xs { padding: 6px; font-size: 0.6rem; } #sai-error-message { position: fixed; top: 0; right: 0; width: 200px; min-height: 100px; background-color: #cb4b4b; padding: 15px; box-shadow: rgb(0 0 0 / 21%) 0px 0px 10px 2px; color: #fff; font-weight: bold; font-size: 12px; } /* ==== SETTINGS ====== */ #sai-settings-view { position: fixed; right: 0; top: 0; width: 100%; background-color: rgb(30 30 30 / 90%); height: 100vh; padding: 25px; z-index: 100000; } #sai-settings-view.sai-hide { display: none; } #sai-settings-view-inner { max-width: 700px; margin: 0 auto; display: flex; justify-content: space-between; } .sai-settings-col { width: 45%; } #sai-settings-header { display: flex; justify-content: space-between; align-items: flex-start; max-width: 700px; margin: 0 auto; border-bottom: 1px solid #777; margin-bottom: 25px; padding-bottom: 10px; } #sai-settings-view a { color: #1abc9c; text-decoration: none; font-weight: bold; } .sai-button { all: unset; background-color: #1abc9c; color: #fff; padding: 10px 15px; font-weight: bold; border-radius: 5px; font-size: 14px; color: #fff !important; cursor: pointer; line-height: 1.6; } .sai-button:hover { opacity: 0.8; } #sai-settings-view h3, #sai-settings-view h4, #sai-settings-view p { color: #fff; margin-bottom: 25px; } #sai-settings-view li { color: #fff; } #sai-settings-view h3 { font-size: 20px; } #sai-settings-view h4 { font-size: 17px; font-weight:bold; margin-bottom: 15px; } .sai-settings-section { margin-top: 35px; padding-top: 25px; border-top: 1px solid #777; } #sai-settings-view li strong { color: #ffca92; } #sai-settings-view ul { padding-left: 0; margin: 0; list-style: none; } #sai-settings-view li { margin-top: 10px; } #sai-settings-read-aloud-header { } #sai-settings-voice-link { display: inline-block; margin-top: 7px; font-size: 12px; } .sai-slidecontainer { width: 100%; } .sai-slider { -webkit-appearance: none; width: 100%; height: 15px; border-radius: 5px; background: #d3d3d3; outline: none; opacity: 0.7; -webkit-transition: 0.2s; transition: opacity 0.2s; } .sai-slider:hover { opacity: 1; } .sai-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 25px; height: 25px; border-radius: 50%; background: #1abc9c; cursor: pointer; } .sai-link-talkio { color: #ac99ff !important; } @media only screen and (max-height: 720px) { #sai-settings-header { margin-bottom: 15px; padding-bottom: 0; } #sai-settings-view { font-size: 12px; overflow-y: auto; } #sai-settings-view h4 { font-size: 16px; } .sai-settings-section { margin-top: 20px; padding-top: 10px; } } /* ======== REPEAT BUTTON ======= */ .sai-repeat-button { border-radius: 5px; width: 30px; height: 30px; cursor: pointer; position: relative; } .sai-repeat-button.sai-disabled { display: none; } .sai-repeat-button svg { width: 18px; height: 18px; margin-top: 5px; margin-left: 5px; } .sai-repeat-button path { fill: #5d5d5d; } .sai-repeat-button:hover { background: #40414f; } .sai-repeat-button:hover path { fill: #b4b4b4; } .dark .sai-repeat-button path { fill: #b4b4b4; } .dark .sai-repeat-button:hover { background: #40414f; } .dark .sai-repeat-button:hover path { fill: #fff; } /* ======== HIDE SAI ======= */ .sai-hidden #sai-input-wrapper, .sai-hidden #sai-lang-selector-wrapper, .sai-hidden #sai-skip-read-aloud, .sai-hidden #sai-disable-read-aloud { display: none; } .sai-hidden #sai-button-wrapper { background: transparent; padding: 0; } .sai-hidden #sai-settings-button { border-radius: 5px; position: fixed; top: 7px; right: 45px; z-index: 10000; } @media only screen and (min-width: 768px) { .sai-hidden #sai-settings-button { top: 20px; right: 20px } } @media only screen and (max-width: 768px) { form > div.relative.flex.h-full { flex-direction: column; } #sai-input-wrapper { height: 50px; } } /* ======== SAI COMPACT ======= */ .sai-compact #sai-root { height: 0; margin: 0; position: relative; } .sai-compact #sai-input-wrapper{ position: absolute; width: 30px; height: 30px; right: 10px; top: 8px; border: none; z-index: 10; } .sai-compact #sai-input-wrapper.is-idle { background: none; border: none; box-shadow: none; opacity: 0.5; } .sai-compact .sai-input { display: none !important; } .sai-compact #sai-speech-button { width: 20px !important; height: 20px !important; top: 4px !important; right: 0 !important; margin-right: 4px !important; } .sai-compact #sai-speech-button svg { width: 20px !important; height: 20px !important; } .sai-compact #sai-input-wrapper.is-idle #sai-speech-button svg path { fill: #999; } .sai-compact #sai-cancel-msg { display: none; } .sai-compact #sai-button-wrapper { position: absolute; bottom: 15px; right: 0; padding: 5px 7px; } .sai-compact #sai-lang-selector { font-size: 10px !important; height: 25px; } .sai-compact #sai-settings-button svg, .sai-compact #sai-disable-read-aloud svg{ width: 20px !important; height: 20px !important; margin-top: 0px !important; } .sai-compact #sai-skip-read-aloud svg { width: 20px !important; height: 13px !important; margin-top: 5px !important; } .sai-compact #sai-disable-read-aloud.disabled:before { left: 11px; bottom: 1px; } .sai-compact textarea { padding-right: 4rem !important; } .sai-compact textarea + button { margin-right: 35px; } @media only screen and (max-width: 900px) { .sai-compact .flex.ml-1.gap-0.justify-center{ position: static; justify-content: flex-start !important; } } @media only screen and (max-width: 768px) { .sai-compact .w-full.h-32.flex-shrink-0 { margin-top: 25px; } .sai-compact .flex.ml-1.gap-0.justify-center{ position: absolute; bottom: 62px; height: 30px; } } @media only screen and (min-width: 768px) { .sai-compact #sai-input-wrapper { top: 12px; } .sai-compact #sai-button-wrapper { bottom: 10px; } .sai-compact .flex.ml-1.gap-0.justify-center{ position: absolute; top: -46px; max-height: 36px; } } ` document.body.appendChild(style); var logLevel; (function(e) { e.info = "info", e.warning = "warning", e.error = "error", e.verbose = "verbose", e.success = "success" }(logLevel || (logLevel = {}))); class SAILogger { constructor(logToConsole=!0) { this.logToConsole = logToConsole, window.addEventListener("sai-print-logs", (()=>{ console.log("All logs:"), console.log(SAILogger.allLogs) } )) } static info(msg, data) { this.instance.write(msg, logLevel.info, data) } static success(msg, data) { this.instance.write(msg, logLevel.success, data) } static warn(msg, data) { this.instance.write(msg, logLevel.warning, data) } static error(msg, err) { this.instance.write(msg, logLevel.error, err) } static verbose(msg, data) { this.instance.logToConsole && SAILogger.allLogs.push([Date.now(), logLevel.verbose, msg, data]) } static setup() { if (!SAILogger.instance) { const logToConsole = "true" === window.localStorage.getItem("sai-log"); this.instance = new SAILogger(logToConsole) } return SAILogger.instance } write(msg, level, data) { if (this.logToConsole) { const style = `color: ${this.getConsoleColor(level)}`; data ? console.log(`%c[${level}] ${msg}`, style, data) : console.log(`%c[${level}] ${msg}`, style), SAILogger.allLogs.push([Date.now(), level, msg, data]) } } getConsoleColor(level) { return level === logLevel.info ? "#2e99d9" : level === logLevel.warning ? "#ffbb00" : level === logLevel.success ? "#1abc9c" : "#b91e1e" } } SAILogger.allLogs = []; class ErrorMessage { constructor(element) { this.element = element, this.isVisible = !1 } write(html, delay=3e3) { this.isVisible && clearTimeout(this.timer), this.element.innerHTML = html, this.setVisible(!0), this.timer = setTimeout((()=>{ this.setVisible(!1), this.element.innerHTML = "" } ), delay) } setVisible(v) { this.element.style.display = v ? "block" : "none", this.isVisible = v } } function matchLanguageCode(code, name) { return code === name || ("zh-CN" === code && "cmn-Hans-CN" === name || ("zh-TW" === code && "cmn-Hant-TW" === name || "zh-HK" === code && "yue-Hant-HK" === name)) } const staticLangSupportList = [ ["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 allVoices = []; async function getVoiceSupportList() { if (allVoices.length > 0) return allVoices; const voices = await new Promise((resolve=>{ window.speechSynthesis.onvoiceschanged = ()=>{ const voices = window.speechSynthesis.getVoices(); resolve(voices) } } )); return staticLangSupportList.forEach((langSupport=>{ voices.some((v=>matchLanguageCode(v.lang, langSupport[1]))) ? allVoices.push(langSupport) : SAILogger.warn(`${langSupport[0]} not supported. Removed from selector.`) } )), allVoices } class LanguageSelector { constructor(selectionCb, code) { this.selectionCb = selectionCb, this.selected = code, 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", getVoiceSupportList().then((list=>{ if (0 === list.length) { this.selector.classList.add("sai-hide"); const d = document.createElement("div"); return d.id = "sai-no-voices", d.innerHTML = "Install voices", void this.element.appendChild(d) } list.forEach((([name,code])=>{ const e = document.createElement("option"); e.innerText = name, e.value = code, code === this.selected && (e.selected = !0), this.selector.appendChild(e) } )), this.element.appendChild(this.selector), this.selector.onchange = event=>{ const t = event.target; this.selectLanguage(t.value) } } )) } selectLanguage(code) { window.localStorage.setItem(this.storageKey, code), this.selectionCb(code) } setDefaultFromStorage() { let code = window.localStorage.getItem(this.storageKey); code && (this.selected = code, this.selectLanguage(code)) } } class ReadAloudImpl { constructor(lang, reset, waitForContent) { this.lang = lang, this.waitForContent = waitForContent, 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\x3c!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --\x3e\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(), SAILogger.info(`reInit ${reset}, lastTextLength: ${this.lastText.length}`), reset && this.reset() } async runQueue() { if (SAILogger.info(`Queue is idle: ${this.queueIdle}`), this.queue.length > 0 && this.queueIdle) { this.skipButton.classList.add("sai-active"), this.queueIdle = !1; const text = this.queue.shift(); await this.readAloud(text), this.queueIdle = !0, this.skipButton.classList.remove("sai-active"), this.queue.length > 0 && this.runQueue() } } observerCallback(callback) { const text = this.getText(); if (0 === text.length) SAILogger.info("No text, reset"), this.reset(); else if (this.waitForContent) return SAILogger.info("Wait for content"), this.lastText = text, void (this.waitForContent = !1); const leftText = text.replace(this.lastText.trim(), "").trim() , lastChar = leftText[leftText.length - 1] , longTimeSince = this.lastRead + 1e4 < Date.now(); if (leftText.length > 0 && ("." === lastChar || "?" === lastChar || "!" === lastChar || ":" === lastChar || "。" === lastChar || longTimeSince)) { longTimeSince && (SAILogger.warn(`Long time since last read. Queue length: ${this.queue.length}`), this.queueIdle = !0), SAILogger.info(`Push to queue: ${leftText}`); leftText.split(".").filter((t=>t.length > 0)).forEach((t=>{ this.queue.push(t) } )), this.runQueue(), this.lastRead = Date.now(), this.lastText = text } } setLang(langCode) { this.lang = langCode } skipReading() { this.synth.cancel(), this.queue = [], this.queueIdle = !0; const bases = document.querySelectorAll(".text-base"); for (var i = 0; i < bases.length; i++) bases[i]?.classList.add("sai-skip") } repeat(markdown) { this.synth.cancel(), this.queue = [], this.queueIdle = !0; const text = this.getText(markdown); SAILogger.info(`Repeat: ${text}`), this.queue.push(text), this.runQueue() } readAloud(text) { return new Promise(((resolve,reject)=>{ if (!this.enabled) return SAILogger.info("Read aloud disabled"), void resolve(void 0); if (!text) return SAILogger.info("No text to read"), void resolve(void 0); if (!document.getElementById("sai-root")) return void resolve(void 0); let formatText = text.replace(/([0-9]+)\.(?=[0-9]+(?!\.))/g, "$1,"); this.synth = window.speechSynthesis; const utterThis = new SpeechSynthesisUtterance(formatText) , langVoices = this.synth.getVoices().reverse().filter((v=>matchLanguageCode(v.lang, this.lang))) , preferenceVoice = window.localStorage.getItem("sai-voice-preference" + this.lang) , voice = langVoices.find((v=>v.voiceURI === preferenceVoice)) ?? langVoices[0]; if (!voice) throw new Error(`unknown voice: ${voice} lang: ${this.lang}`); const volume = window.localStorage.getItem("sai-voice-volume-v2"); volume || SAILogger.error("No volume stored in storage"); utterThis.volume = volume / 20, utterThis.voice = voice; const speed = window.localStorage.getItem("sai-voice-speed-v2"); speed || SAILogger.error("No speed stored in storage"); // speedStringToRate const rate = function(speed) { switch (speed) { 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 } }(speed); let timer; utterThis.rate = rate, SAILogger.success(`Voice name: ${voice.name}, lang: ${voice.lang}, rate: ${rate}: ${formatText}`); const read = ()=>{ const [timeout,timeSinceLastUtter] = function(lang, rate, lastUtter, lastTimeout, lastUtterCharCount, lastTimeSinceLastUtter) { const timeSinceLastUtter = Date.now() - lastUtter , timeout = function(lang, lastUtterCharCount, rate) { let i = 100; return "zh-CN" !== lang && "zh-TW" !== lang && "zh-HK" !== lang || (i = 240), "zh-TW" === lang && (i = 300), "ja-JP" === lang && (i = 260), "ko-KR" === lang && (i = 240), 7e3 + lastUtterCharCount * i * (1 / rate) }(lang, lastUtterCharCount, rate); if (SAILogger.warn(`[resumeInfinity] Time since last utter: ${timeSinceLastUtter.toFixed(1)}. Timeout: ${timeout.toFixed(1)}. Last char count: ${lastUtterCharCount}`), window.navigator.userAgent.search("Mac") > -1 && 0 === timeSinceLastUtter && lastTimeSinceLastUtter > 0) { const diff = lastTimeout - lastTimeSinceLastUtter , n = diff / lastTimeout * 100; SAILogger.warn(`Last timeout safety gap: ${diff.toFixed(1)}ms. ${n.toFixed(1)}%`), n < 25 && SAILogger.error(`________Safety gap ${n.toFixed(1)}% too low!________`) } return timeSinceLastUtter > timeout ? (SAILogger.error(`No utter timeout ${timeout.toFixed(1)} - cancel.`), window.speechSynthesis.cancel(), setTimeout((()=>{ window.speechSynthesis.resume() } ), 50), [0, 0]) : [timeout, timeSinceLastUtter] }(voice.lang, rate, this.lastUtter, this.lastTimeout, this.lastUtterCharCount, this.lastTimeSinceLastUtter); this.lastTimeout = timeout, this.lastTimeSinceLastUtter = timeSinceLastUtter, window.speechSynthesis.pause(), window.speechSynthesis.resume(), timer = setTimeout(read, 7e3) } ; utterThis.addEventListener("error", (e=>{ SAILogger.error(`Read aloud error ${e.error}`, e), resolve(void 0), clearTimeout(timer) } )), utterThis.addEventListener("start", (()=>{ SAILogger.info(`Speech has started. Volume: ${utterThis.volume}`), this.lastUtter = Date.now(), read() } )), utterThis.addEventListener("end", (function(e) { SAILogger.info("Speech has ended"), resolve(void 0), clearTimeout(timer) } )), utterThis.addEventListener("pause", (function(e) { SAILogger.verbose("Speech has paused", e) } )), utterThis.addEventListener("resume", (function(e) { SAILogger.verbose("Speech has resumed", e) } )), utterThis.addEventListener("boundary", (function(e) { SAILogger.verbose(`Speech reached boundary. CharIndex: ${e.charIndex}`, e) } )), utterThis.addEventListener("mark", (function(e) { SAILogger.info("Speech reached mark", e) } )), this.synth.speak(utterThis), this.lastUtterCharCount = formatText.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 enabled = window.localStorage.getItem(this.storageKey); enabled && (this.enabled = "true" === enabled, this.enabled ? this.enableReadAloud() : this.disableReadAloud()) } getText(markdown) { const markdownElements = document.querySelectorAll(".text-base:not(.sai-skip) .markdown") , children = (markdown || markdownElements[markdownElements.length - 1])?.children ?? []; let text = ""; for (const child of children) "PRE" !== child.nodeName && (text += child.textContent); return text = text.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, ". "), text } reset() { SAILogger.warn("RESET read aloud queue"), this.queue = [], this.lastRead = Date.now() } } class SpeechImpl { constructor(lang, errorMessage, speechCallback) { this.lang = lang, this.errorMessage = errorMessage, this.transcript = "", this.recognition = new webkitSpeechRecognition, this.isRecording = !1, this.recognition.continuous = !0, this.recognition.interimResults = !0, this.recognition.onstart = ()=>{} , this.recognition.onresult = event=>{ let n = ""; for (let i = event.resultIndex; i < event.results.length; ++i) event.results[i].isFinal ? this.isRecording && (this.transcript += event.results[i][0].transcript, speechCallback(this.transcript)) : n += event.results[i][0].transcript; this.isRecording && speechCallback(this.transcript + n) } , this.recognition.onerror = event=>{ let error = event.error; "not-allowed" === event.error && (error = "The webpage is not allowed to access your microphone"), "no-speech" === event.error && (error = "No sound from the microphone"); let html = `\n \n Error from Voice Control:\n
\n ${error}\n

\n \n See voicecontrol.chat/support for help\n \n
\n `; this.errorMessage.write(html, 8e3), SAILogger.error(`recognition.onerror ${event.error}`) } , this.recognition.onend = ()=>{ SAILogger.info("Ended"), this.endCallback?.() } } start(endCallback) { SAILogger.info("Start"), this.endCallback = endCallback, this.recognition.lang = this.lang, this.recognition.start(), this.isRecording = !0 } stop() { SAILogger.info(`Stop: ${this.transcript}`), this.isRecording = !1, this.recognition.stop(), this.endCallback = void 0 } reset() { this.isRecording = !1, this.transcript = "" } setLang(langCode) { this.lang = langCode } } class SettingsImpl { constructor(readAloud) { this.readAloud = readAloud, 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 = `

Voice Control for ChatGPT

Read aloud speed:

Read aloud volume:

Voice preference

Install more voices

Display settings

Need help or have a suggestion?

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

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

Keyboard shortcuts

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

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

`, this.settingsView.id = "sai-settings-view", this.settingsView.classList.add("sai-hide") } setupListeners() { const sliderSpeedElement = document.getElementById("sai-popup-range-slider-speed") , sliderVolumeElement = document.getElementById("sai-popup-range-slider-volume") , speedElement = document.getElementById("sai-read-aloud-speed") , volumeElement = document.getElementById("sai-read-aloud-volume") , closeSettingsElement = document.getElementById("sai-close-settings") , displayToggleElement = document.getElementById("sai-display-toggle") , uiToggleElement = document.getElementById("sai-ui-toggle"); if (!(sliderSpeedElement && sliderVolumeElement && speedElement && volumeElement && closeSettingsElement && displayToggleElement && uiToggleElement)) return void SAILogger.warn("settings element missing"); closeSettingsElement.onclick = ()=>{ this.settingsView.classList.add("sai-hide") }, displayToggleElement.onclick = ()=>{ document.body.classList.toggle("sai-hidden"); const hidden = window.localStorage.getItem('"sai-hidden"'); hidden && "true" === hidden ? (window.localStorage.setItem('"sai-hidden"', "false"), this.appIsHidden = !1, displayToggleElement.innerText = "Hide Voice Control") : (window.localStorage.setItem('"sai-hidden"', "true"), this.appIsHidden = !0, this.readAloud.disableReadAloud(), displayToggleElement.innerText = "Show Voice Control") }, uiToggleElement.onclick = ()=>{ document.body.classList.toggle("sai-compact"); const showCompactUi = window.localStorage.getItem("sai-compact-ui"); showCompactUi && "true" === showCompactUi ? (window.localStorage.setItem("sai-compact-ui", "false"), this.showCompactUi = !1, uiToggleElement.innerText = "Compact interface") : (window.localStorage.setItem("sai-compact-ui", "true"), this.showCompactUi = !0, uiToggleElement.innerText = "Classic interface"); document.getElementById("sai-root")?.remove() }; const updateSpeed = speed=>{ speedElement.innerHTML = speed; sliderSpeedElement.value = speed, window.localStorage.setItem("sai-voice-speed-v2", speed) }; const updateVolume = volume=>{ volumeElement.innerHTML = volume; sliderVolumeElement.value = volume, window.localStorage.setItem("sai-voice-volume-v2", volume) }; sliderSpeedElement.oninput = event=>{ const t = event.target; updateSpeed(t.value) }; sliderVolumeElement.oninput = event=>{ const t = event.target; updateVolume(t.value) }; const voiceSpeed = window.localStorage.getItem("sai-voice-speed-v2"); voiceSpeed && updateSpeed(voiceSpeed) const voiceVolume = window.localStorage.getItem("sai-voice-volume-v2"); voiceVolume && updateVolume(voiceVolume) } createVoiceSelector() { const langCode = window.localStorage.getItem("sai-language") ?? "en-US" , filteredVoices = window.speechSynthesis.getVoices().filter((v=>matchLanguageCode(v.lang, langCode))).reverse() , preferenceVoice = window.localStorage.getItem("sai-voice-preference" + langCode) , selectElement = document.createElement("select"); selectElement.id = "sai-voice-selector", selectElement.style.color = "black", selectElement.style.width = "100%", filteredVoices.forEach((v=>{ const element = document.createElement("option"); element.innerText = v.name, element.value = v.voiceURI, preferenceVoice === element.value && (element.selected = !0), selectElement.appendChild(element) } )), selectElement.onchange = event=>{ const t = event.target; window.localStorage.setItem("sai-voice-preference" + langCode, t.value) } ; const voiceSettingsElement = document.getElementById("sai-voice-settings"); voiceSettingsElement && (voiceSettingsElement.innerHTML = "", voiceSettingsElement.appendChild(selectElement)) } labelFromSpeedValue(speed) { return speed } } class RepeatButton { constructor(readAloud) { const saiEnabled = "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"), saiEnabled || this.element.classList.add("sai-disabled"), this.element.onclick = ()=>{ const markdown = this.element.closest(".text-base")?.querySelector(".markdown"); markdown ? readAloud.repeat(markdown) : SAILogger.warn("Could not find text element to repeat") } } } class RepeatHandler { constructor(readAloud) { this.readAloud = readAloud; } injectRepeatButtons() { document.querySelectorAll("article .text-base .flex-col .items-center .flex.items-center>div.flex").forEach((element=>{ if (element.querySelectorAll(".sai-repeat-button").length > 0) return; const repeatButton = new RepeatButton(this.readAloud); element.appendChild(repeatButton.element) })) } } const defaultLanguageCode = staticLangSupportList[0][1]; class VoiceControl { constructor(reset=!1, waitForContent=!1) { this.isRecording = !1, this.language = defaultLanguageCode, this.spaceIsDown = !1, this.isCompact = "true" === window.localStorage.getItem("sai-compact-ui"), SAILogger.info("Init app"); const chatGptInput = document.querySelector("textarea") , chatGptInputParent = document.querySelector('#composer-background'); if (!chatGptInput || !chatGptInputParent) throw new Error("Missing elements"); this.chatGptInput = chatGptInput, this.chatGptInputParent = chatGptInputParent, this.saiRoot = document.createElement("div"), this.saiRoot.id = "sai-root", this.saiInput = document.createElement("div"), this.saiInput.className = chatGptInput.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\x3c!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --\x3e\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 ErrorMessage(this.saiErrorMessage), this.speech = new SpeechImpl(this.language,this.errorMessage,this.speechCallback.bind(this)), this.readAloud = new ReadAloudImpl(this.language,reset, waitForContent), this.settings = new SettingsImpl(this.readAloud), this.speechHandlers(); const langSelector = new LanguageSelector(this.setLanguage.bind(this), defaultLanguageCode); this.saiButtonWrapper.appendChild(langSelector.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 RepeatHandler(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(event) { const t = event.target; if ("textarea" === t?.localName || "Space" !== event.code || this.spaceIsDown || (this.holdSpaceTimer = setTimeout((()=>{ SAILogger.info("Space start"), this.startRecording(), this.speech.start((()=>{ this.stopRecording() } )) } ), 250), this.spaceIsDown = !0), "textarea" !== t?.localName && "Space" === event.code && this.isRecording && this.isCompact && this.appToIdle(), "textarea" === t?.localName || "Escape" !== event.code && "KeyQ" !== event.code || !this.isRecording || (SAILogger.info(`Pressed ${event.code}`), this.appToIdle()), ("Escape" === event.code || "KeyQ" === event.code) && !this.isRecording) { SAILogger.info(`Pressed ${event.code}. Close settings`); document.getElementById("sai-settings-view")?.classList.add("sai-hide") } "KeyE" === event.code && this.isRecording && (SAILogger.info("Pressed KeyE"), this.chatGptInput.value = this.saiInput.innerText, this.appToIdle()), "textarea" !== t?.localName && "Enter" === event.code && this.isRecording && (SAILogger.info("Enter stop"), this.submitToChatGPT(this.chatGptInput.value), this.appToIdle()) } keyUpHandler(event) { this.spaceIsDown && "Space" === event.code && (this.isCompact || (SAILogger.info("Space stop"), this.stopRecording())) } onSubmit() { SAILogger.info("on Submit"), this.appToIdle() } adjustCompactIconPos() { if (!this.isCompact) return; const offsetHeight = this.chatGptInput.offsetHeight , icon = document.querySelector("textarea + button.absolute.p-1.rounded-md"); icon && (icon.style.marginRight = offsetHeight > 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 text = this.saiInput.innerText; this.submitToChatGPT(text) } this.appToIdle() } submitToChatGPT(text) { text.length > 0 && (this.chatGptInput.value += text, this.chatGptInput.dispatchEvent(new Event("input",{ bubbles: !0 })), setTimeout(() => this.chatGptInputParent.querySelectorAll('button')[6].click(), 100) ) } 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(transcript) { this.isCompact ? transcript.length > 0 && (this.chatGptInput.value = transcript, this.chatGptInput.dispatchEvent(new Event("input",{ bubbles: !0 }))) : this.saiInput.innerText = transcript } setLanguage(langCode) { this.language = langCode, this.readAloud.setLang(langCode), this.speech.setLang(langCode) } } SAILogger.setup(); window.localStorage.getItem("sai-voice-speed-v2") || window.localStorage.setItem("sai-voice-speed-v2", "10"); window.localStorage.getItem("sai-voice-volume-v2") || window.localStorage.setItem("sai-voice-volume-v2", "10"); "true" === window.localStorage.getItem('"sai-hidden"') && document.body.classList.add("sai-hidden"); function initApp() { const fnCheckPath = () => /^\/c\/(.*)$/.test(window.location.pathname); let vc = new VoiceControl(!1, fnCheckPath()) , flag = !1; const keydownListener = event=>{ vc.keyDownHandler(event) } , keyupListener = event=>{ vc.keyUpHandler(event) } , submitListener = event=>{ "Enter" === event.code && vc.onSubmit() } , clickListener = ()=>{ vc.onSubmit() } ; document.addEventListener("keydown", keydownListener), document.addEventListener("keyup", keyupListener); const buttonSelector = "form button.absolute.p-1.rounded-md.text-gray-500"; function callback(observerCallback) { const root = document.getElementById("sai-root") , textarea = document.querySelector("textarea"); vc?.adjustCompactIconPos(), vc?.repeatHandler?.injectRepeatButtons(), flag && textarea && (SAILogger.info("Re-init app"), root && root.remove(), vc = new VoiceControl(!0,fnCheckPath()), flag = !1, document.addEventListener("keydown", keydownListener), document.addEventListener("keyup", keyupListener), document.querySelector("textarea")?.addEventListener("keyup", submitListener), document.querySelector(buttonSelector)?.addEventListener("click", clickListener)), root && textarea || (SAILogger.warn("App removed"), root && root.remove(), vc.readAloud.reset(), document.removeEventListener("keydown", keydownListener), document.removeEventListener("keyup", keyupListener), document.querySelector("textarea")?.removeEventListener("keyup", submitListener), document.querySelector(buttonSelector)?.removeEventListener("click", clickListener), flag = !0), root && vc.readAloud.observerCallback(observerCallback) } document.querySelector("textarea")?.addEventListener("keyup", submitListener), document.querySelector(buttonSelector)?.addEventListener("click", clickListener); new MutationObserver(callback).observe(document.body, { childList: !0, subtree: !0, characterData: !0 }), setInterval((()=>{ callback([]) } ), 3500) } "true" === window.localStorage.getItem("sai-compact-ui") && document.body.classList.add("sai-compact"), // chrome.runtime.onMessage.addListener(((message, sender, sendResponse)=>("sai-on-chatgpt-message" === message.key && sendResponse({ // value: "yes-we-are-here" // }), // !0))); document.querySelector("textarea") ? initApp() : setTimeout((()=>{ initApp() } ), 2e3); })();