Google Cloud TTS Downloader

Add a Download button, language flags, voice gender for Google Cloud Text-to-Speech AI.

  1. // ==UserScript==
  2. // @name Google Cloud TTS Downloader
  3. // @description Add a Download button, language flags, voice gender for Google Cloud Text-to-Speech AI.
  4. // @icon https://www.google.com/s2/favicons?sz=64&domain=cloud.google.com
  5. // @version 1.3
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match https://www.gstatic.com/cloud-site-ux/text_to_speech/text_to_speech.min.html
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. ;(() => {
  15. const FLAG_BASE_URL = "https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/flags/4x3/"
  16.  
  17. const AUDIO_DEVICE_PROFILES = [
  18. "Default",
  19. "Smart watch or wearable",
  20. "Smartphone",
  21. "Headphones or earbuds",
  22. "Small home speaker",
  23. "Smart home speaker",
  24. "Home entertainment system or smart TV",
  25. "Car speaker",
  26. "Interactive Voice Response (IVR) system",
  27. ]
  28.  
  29. const languageMap = {
  30. textMap: {
  31. "Arabic, multi-region": { code: "sa", text: "Arabic (Multi-region)" },
  32. "Bahasa Indonesia (Indonesia)": { code: "id", text: "Indonesian (Indonesia)" },
  33. "Deutsch (Deutschland)": { code: "de", text: "German (Germany)" },
  34. "English (Australia)": { code: "au", text: "English (Australia)" },
  35. "English (Great Britain)": { code: "gb", text: "English (Great Britain)" },
  36. "English (India)": { code: "in", text: "English (India)" },
  37. "English (United States)": { code: "us", text: "English (United States)" },
  38. "Español (España)": { code: "es", text: "Spanish (Spain)" },
  39. "Español (Estados Unidos)": { code: "us", text: "Spanish (United States)" },
  40. "Français (Canada)": { code: "ca", text: "French (Canada)" },
  41. "Français (France)": { code: "fr", text: "French (France)" },
  42. "Italiano (Italia)": { code: "it", text: "Italian (Italy)" },
  43. "Nederlands (Nederland)": { code: "nl", text: "Dutch (Netherlands)" },
  44. "Polski (Polska)": { code: "pl", text: "Polish (Poland)" },
  45. "Português (Brasil)": { code: "br", text: "Portuguese (Brazil)" },
  46. "Tiếng Việt (Việt Nam)": { code: "vn", text: "Vietnamese (Vietnam)" },
  47. "Türkçe (Türkiye)": { code: "tr", text: "Turkish (Turkey)" },
  48. "Русский (Россия)": { code: "ru", text: "Russian (Russia)" },
  49. "मराठी (भारत)": { code: "in", text: "Marathi (India)" },
  50. "हिन्दी (भारत)": { code: "in", text: "Hindi (India)" },
  51. "বাংলা (ভারত)": { code: "in", text: "Bengali (India)" },
  52. "ગુજરાતી (ભારત)": { code: "in", text: "Gujarati (India)" },
  53. "தமிழ் (இந்தியா)": { code: "in", text: "Tamil (India)" },
  54. "తెలుగు (భారతదేశం)": { code: "in", text: "Telugu (India)" },
  55. "ಕನ್ನಡ (ಭಾರತ)": { code: "in", text: "Kannada (India)" },
  56. "മലയാളം (ഇന്ത്യ)": { code: "in", text: "Malayalam (India)" },
  57. "ไทย (ประเทศไทย)": { code: "th", text: "Thai (Thailand)" },
  58. "日本語(日本)": { code: "jp", text: "Japanese (Japan)" },
  59. "普通话 (中国大陆)": { code: "cn", text: "Mandarin (Mainland China)" },
  60. "한국어 (대한민국)": { code: "kr", text: "Korean (South Korea)" },
  61. },
  62. }
  63.  
  64. const voiceModelMap = {
  65. female: ["Aoede", "Kore", "Leda", "Zephyr"],
  66. male: ["Charon", "Fenrir", "Orus", "Puck"],
  67. }
  68.  
  69. let lastResponse = null
  70. let lastPayload = null
  71. let audioPlayer = null
  72. let downloadButton = null
  73.  
  74. function getVoiceGender(voiceName) {
  75. for (const [gender, voices] of Object.entries(voiceModelMap)) {
  76. if (voices.includes(voiceName)) {
  77. return gender.charAt(0).toUpperCase() + gender.slice(1)
  78. }
  79. }
  80. return "Unknown"
  81. }
  82.  
  83. const originalOpen = XMLHttpRequest.prototype.open
  84. XMLHttpRequest.prototype.open = function (_method, url) {
  85. this.customURL = url
  86. if (url.includes("texttospeech.googleapis.com/v1beta1/text:synthesize")) {
  87. this.addEventListener("readystatechange", function () {
  88. if (this.readyState === 4) {
  89. try {
  90. const response = JSON.parse(this.responseText)
  91. lastResponse = response.audioContent
  92. updateAudioPlayerAndDownload()
  93. } catch (e) {}
  94. }
  95. })
  96. }
  97. originalOpen.apply(this, arguments)
  98. }
  99.  
  100. const originalSend = XMLHttpRequest.prototype.send
  101. XMLHttpRequest.prototype.send = function (data) {
  102. if (this.customURL && this.customURL.includes("texttospeech.googleapis.com/v1beta1/text:synthesize")) {
  103. try {
  104. lastPayload = typeof data === "string" ? JSON.parse(data) : data
  105. } catch (e) {}
  106. }
  107. originalSend.apply(this, arguments)
  108. }
  109.  
  110. const base64ToArrayBuffer = (base64) => {
  111. const binary = atob(base64)
  112. const buffer = new Uint8Array(binary.length)
  113. for (let i = 0; i < binary.length; i++) {
  114. buffer[i] = binary.charCodeAt(i)
  115. }
  116. return buffer.buffer
  117. }
  118.  
  119. const downloadAudio = () => {
  120. if (!lastResponse || !lastPayload) return
  121.  
  122. const now = new Date()
  123. const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`
  124.  
  125. const truncatedText = lastPayload.input.text.substring(0, 25) + "..."
  126. const filename = `${timestamp}_${lastPayload.voice.name}_${truncatedText}.wav`
  127.  
  128. const blob = new Blob([base64ToArrayBuffer(lastResponse)], { type: "audio/wav" })
  129. const link = document.createElement("a")
  130. link.href = URL.createObjectURL(blob)
  131. link.download = filename
  132. link.click()
  133. URL.revokeObjectURL(link.href)
  134. }
  135.  
  136. const createAudioPlayerContainer = () => {
  137. const playerContainer = document.createElement("div")
  138. playerContainer.id = "custom-audio-container"
  139. playerContainer.style.cssText = `
  140. display: flex;
  141. flex-direction: column;
  142. align-items: center;
  143. justify-content: center;
  144. width: 100%;
  145. margin-top: 15px;
  146. padding: 10px;
  147. border-radius: 8px;
  148. `
  149.  
  150. audioPlayer = document.createElement("audio")
  151. audioPlayer.id = "custom-audio-player"
  152. audioPlayer.controls = true
  153. audioPlayer.style.cssText = `
  154. width: 100%;
  155. max-width: 500px;
  156. margin-bottom: 10px;
  157. `
  158.  
  159. downloadButton = document.createElement("paper-button")
  160. downloadButton.setAttribute("role", "button")
  161. downloadButton.setAttribute("tabindex", "0")
  162. downloadButton.setAttribute("animated", "")
  163. downloadButton.setAttribute("elevation", "0")
  164. downloadButton.classList.add("state-paused")
  165. downloadButton.style.backgroundColor = "var(--google-blue-500)"
  166. downloadButton.style.color = "#fff"
  167.  
  168. downloadButton.innerHTML = `
  169. <span class="button-inner">
  170. <span class="label">
  171. <span class="ready">Download</span>
  172. </span>
  173. </span>
  174. `
  175.  
  176. downloadButton.addEventListener("click", downloadAudio)
  177.  
  178. playerContainer.appendChild(audioPlayer)
  179. playerContainer.appendChild(downloadButton)
  180.  
  181. return playerContainer
  182. }
  183.  
  184. const updateAudioPlayerAndDownload = () => {
  185. if (!lastResponse) return
  186.  
  187. const existingContainer = document.getElementById("custom-audio-container")
  188. if (existingContainer) {
  189. const existingAudio = existingContainer.querySelector("audio")
  190. if (existingAudio && existingAudio.src) {
  191. URL.revokeObjectURL(existingAudio.src)
  192. }
  193. existingContainer.remove()
  194. }
  195.  
  196. const blob = new Blob([base64ToArrayBuffer(lastResponse)], { type: "audio/wav" })
  197. const audioUrl = URL.createObjectURL(blob)
  198.  
  199. const playerContainer = createAudioPlayerContainer()
  200. audioPlayer.src = audioUrl
  201.  
  202. const app = document.querySelector("ts-app")
  203. if (app && app.shadowRoot) {
  204. const controlPlayback = app.shadowRoot.querySelector(".control-playback")
  205. if (controlPlayback) {
  206. const existingContainers = app.shadowRoot.querySelectorAll("#custom-audio-container")
  207. existingContainers.forEach((container) => container.remove())
  208.  
  209. controlPlayback.insertAdjacentElement("afterend", playerContainer)
  210. }
  211. }
  212. }
  213.  
  214. function enhanceLanguageAndVoice() {
  215. let enhancedItems = 0
  216.  
  217. function processRoot(root) {
  218. if (!root) return 0
  219.  
  220. try {
  221. const items = root.querySelectorAll("paper-item")
  222. let count = 0
  223.  
  224. items.forEach((item) => {
  225. if (!item) return
  226.  
  227. if (item.dataset.enhanced === "true") return
  228.  
  229. const originalText = item.textContent ? item.textContent.trim() : ""
  230.  
  231. const langInfo = languageMap.textMap[originalText]
  232. if (langInfo) {
  233. const wrapper = document.createElement("div")
  234. wrapper.style.display = "flex"
  235. wrapper.style.alignItems = "center"
  236. wrapper.style.gap = "8px"
  237.  
  238. const flagImg = document.createElement("img")
  239. flagImg.src = `${FLAG_BASE_URL}${langInfo.code}.svg`
  240. flagImg.style.width = "24px"
  241. flagImg.style.height = "18px"
  242. flagImg.style.marginRight = "5px"
  243.  
  244. const textSpan = document.createElement("span")
  245. textSpan.textContent = langInfo.text
  246.  
  247. wrapper.appendChild(flagImg)
  248. wrapper.appendChild(textSpan)
  249.  
  250. item.innerHTML = ""
  251. item.appendChild(wrapper)
  252. item.dataset.enhanced = "true"
  253. count++
  254.  
  255. item.addEventListener("click", () => {
  256. localStorage.setItem("lastSelectedLanguage", langInfo.text)
  257. })
  258. }
  259.  
  260. const voiceModelMatch = originalText.match(/^[a-z]{2,3}(-[A-Z]{1,2})?-Chirp3-HD-(\w+)$/)
  261. if (voiceModelMatch) {
  262. const voiceModelName = voiceModelMatch[2]
  263. const voiceGender = getVoiceGender(voiceModelName)
  264.  
  265. if (voiceGender !== "Unknown") {
  266. item.textContent = `${voiceModelName} (${voiceGender})`
  267. item.dataset.enhanced = "true"
  268. count++
  269. }
  270. }
  271.  
  272. if (AUDIO_DEVICE_PROFILES.includes(originalText)) {
  273. item.dataset.enhanced = "true"
  274. item.addEventListener("click", () => {
  275. localStorage.setItem("lastSelectedAudioDeviceProfile", originalText)
  276. })
  277. }
  278. })
  279.  
  280. return count
  281. } catch (error) {
  282. return 0
  283. }
  284. }
  285.  
  286. function traverseDeepDOM(element) {
  287. if (!element) return 0
  288.  
  289. try {
  290. let count = processRoot(element)
  291.  
  292. if (element.shadowRoot) {
  293. count += processRoot(element.shadowRoot)
  294. }
  295.  
  296. const children = element.children || []
  297. for (const child of children) {
  298. if (child) {
  299. count += traverseDeepDOM(child)
  300. }
  301. }
  302.  
  303. return count
  304. } catch (error) {
  305. return 0
  306. }
  307. }
  308.  
  309. const searchRoots = [document.body, document, document.documentElement, window.document]
  310.  
  311. searchRoots.forEach((root) => {
  312. if (root) {
  313. enhancedItems += traverseDeepDOM(root)
  314. }
  315. })
  316.  
  317. return enhancedItems
  318. }
  319.  
  320. function restoreLastSelection() {
  321. const lastLanguage = localStorage.getItem("lastSelectedLanguage")
  322. const lastAudioDeviceProfile = localStorage.getItem("lastSelectedAudioDeviceProfile")
  323.  
  324. function findAndClickItem(text) {
  325. const searchInRoot = (root) => {
  326. if (!root) return false
  327.  
  328. const items = root.querySelectorAll("paper-item")
  329. for (const item of items) {
  330. if (item.textContent && item.textContent.trim() === text) {
  331. item.click()
  332. return true
  333. }
  334. }
  335. return false
  336. }
  337. ;[document.body, document, document.documentElement, window.document].forEach((root) => {
  338. if (root) {
  339. searchInRoot(root)
  340.  
  341. const elements = root.querySelectorAll("*")
  342. for (const el of elements) {
  343. if (el.shadowRoot) {
  344. searchInRoot(el.shadowRoot)
  345. }
  346. }
  347. }
  348. })
  349. }
  350.  
  351. if (lastLanguage) findAndClickItem(lastLanguage)
  352. if (lastAudioDeviceProfile) findAndClickItem(lastAudioDeviceProfile)
  353. }
  354.  
  355. function waitForElementsAndEnhance() {
  356. const enhancedCount = enhanceLanguageAndVoice()
  357.  
  358. if (enhancedCount > 0) {
  359. restoreLastSelection()
  360. setupObserver()
  361. } else {
  362. setTimeout(waitForElementsAndEnhance, 200)
  363. }
  364. }
  365.  
  366. function setupObserver() {
  367. const observer = new MutationObserver(() => {
  368. enhanceLanguageAndVoice()
  369. })
  370.  
  371. observer.observe(document.body, {
  372. childList: true,
  373. subtree: true,
  374. attributes: true,
  375. })
  376.  
  377. document.addEventListener(
  378. "click",
  379. () => {
  380. setTimeout(enhanceLanguageAndVoice, 100)
  381. },
  382. true,
  383. )
  384. }
  385.  
  386. function waitForApp() {
  387. const app = document.querySelector("ts-app")
  388. if (app && app.shadowRoot) {
  389. waitForElementsAndEnhance()
  390. } else {
  391. requestAnimationFrame(waitForApp)
  392. }
  393. }
  394.  
  395. waitForApp()
  396. })()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址