Text-to-Speech Reader

Read selected text using OpenAI TTS API

  1. // ==UserScript==
  2. // @name Text-to-Speech Reader
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.6
  5. // @description Read selected text using OpenAI TTS API
  6. // @author https://linux.do/u/snaily,https://linux.do/u/joegodwanggod
  7. // @match *://*/*
  8. // @grant GM_xmlhttpRequest
  9. // @grant GM_registerMenuCommand
  10. // @grant GM_addStyle
  11. // @grant GM_setValue
  12. // @grant GM_getValue
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function () {
  17. "use strict";
  18.  
  19. // 创建按钮
  20. const button = document.createElement("button");
  21. button.innerText = "TTS";
  22. button.style.position = "absolute";
  23. button.style.width = "auto";
  24. button.style.zIndex = "1000";
  25. button.style.display = "none"; // 初始隐藏
  26. button.style.backgroundColor = "#007BFF"; // 蓝色背景
  27. button.style.color = "#FFFFFF"; // 白色文字
  28. button.style.border = "none";
  29. button.style.borderRadius = "3px"; // 调整圆角
  30. button.style.padding = "5px 10px"; // 减少内边距
  31. button.style.boxShadow = "0 2px 5px rgba(0, 0, 0, 0.2)";
  32. button.style.cursor = "pointer";
  33. button.style.fontSize = "12px";
  34. button.style.fontFamily = "Arial, sans-serif";
  35. document.body.appendChild(button);
  36.  
  37. // 获取选中的文本
  38. function getSelectedText() {
  39. let text = "";
  40. if (window.getSelection) {
  41. text = window.getSelection().toString();
  42. } else if (document.selection && document.selection.type != "Control") {
  43. text = document.selection.createRange().text;
  44. }
  45. console.log("Selected Text:", text); // 调试用
  46. return text;
  47. }
  48.  
  49. // 判断文本是否为有效内容 (非空白)
  50. function isTextValid(text) {
  51. return text.trim().length > 0;
  52. }
  53.  
  54. // 调用 OpenAI TTS API
  55. function callOpenAITTS(text, baseUrl, apiKey, voice, model) {
  56. const cachedAudioUrl = getCachedAudio(text);
  57. if (cachedAudioUrl) {
  58. console.log("使用缓存的音频");
  59. playAudio(cachedAudioUrl);
  60. resetButton();
  61. return;
  62. }
  63.  
  64. const url = `${baseUrl}/v1/audio/speech`;
  65. console.log("调用 OpenAI TTS API,文本:", text);
  66. GM_xmlhttpRequest({
  67. method: "POST",
  68. url: url,
  69. headers: {
  70. "Content-Type": "application/json",
  71. Authorization: `Bearer ${apiKey}`,
  72. },
  73. data: JSON.stringify({
  74. model: model,
  75. input: text,
  76. voice: voice,
  77. }),
  78. responseType: "arraybuffer",
  79. onload: function (response) {
  80. if (response.status === 200) {
  81. console.log("API 调用成功"); // 调试用
  82. const audioBlob = new Blob([response.response], {
  83. type: "audio/mpeg",
  84. });
  85. const audioUrl = URL.createObjectURL(audioBlob);
  86. playAudio(audioUrl);
  87. cacheAudio(text, audioUrl);
  88. } else {
  89. console.error("错误:", response.statusText);
  90. showCustomAlert(
  91. `TTS API 错误:${response.status} ${response.statusText}`
  92. );
  93. }
  94. // 请求完成后重置按钮
  95. resetButton();
  96. },
  97. onerror: function (error) {
  98. console.error("请求失败", error);
  99. showCustomAlert("TTS API 请求失败。");
  100. // 请求失败后重置按钮
  101. resetButton();
  102. },
  103. });
  104. }
  105.  
  106. // 播放音频
  107. function playAudio(url) {
  108. const audio = new Audio(url);
  109. audio.play();
  110. }
  111.  
  112. // 使用浏览器内建 TTS
  113. function speakText(text) {
  114. const utterance = new SpeechSynthesisUtterance(text);
  115. speechSynthesis.speak(utterance);
  116. }
  117.  
  118. // 设置按钮为加载状态
  119. function setLoadingState() {
  120. button.disabled = true;
  121. button.innerText = "Loading";
  122. button.style.backgroundColor = "#6c757d"; // 灰色背景
  123. button.style.cursor = "not-allowed";
  124. }
  125.  
  126. // 重置按钮到原始状态
  127. function resetButton() {
  128. button.disabled = false;
  129. button.innerText = "TTS";
  130. button.style.backgroundColor = "#007BFF"; // 蓝色背景
  131. button.style.cursor = "pointer";
  132. }
  133.  
  134. // 获取缓存的音频 URL
  135. function getCachedAudio(text) {
  136. const cache = GM_getValue("cache", {});
  137. const item = cache[text];
  138. if (item) {
  139. const now = new Date().getTime();
  140. const weekInMillis = 7 * 24 * 60 * 60 * 1000; // 一周的毫秒数
  141. if (now - item.timestamp < weekInMillis) {
  142. return item.audioUrl;
  143. } else {
  144. delete cache[text]; // 删除过期的缓存
  145. GM_setValue("cache", cache);
  146. }
  147. }
  148. return null;
  149. }
  150.  
  151. // 缓存音频 URL
  152. function cacheAudio(text, audioUrl) {
  153. const cache = GM_getValue("cache", {});
  154. cache[text] = {
  155. audioUrl: audioUrl,
  156. timestamp: new Date().getTime(),
  157. };
  158. GM_setValue("cache", cache);
  159. }
  160.  
  161. // 清除缓存
  162. function clearCache() {
  163. GM_setValue("cache", {});
  164. showCustomAlert("缓存已成功清除。");
  165. }
  166.  
  167. // 按钮点击事件
  168. button.addEventListener("click", (event) => {
  169. event.stopPropagation(); // 防止点击按钮时触发全局点击事件
  170. const selectedText = getSelectedText();
  171. if (selectedText && isTextValid(selectedText)) {
  172. // 添加有效性检查
  173. let apiKey = GM_getValue("apiKey", null);
  174. let baseUrl = GM_getValue("baseUrl", null);
  175. let voice = GM_getValue("voice", "onyx"); // 默认为 'onyx'
  176. let model = GM_getValue("model", "tts-1"); // 默认为 'tts-1'
  177. if (!baseUrl) {
  178. showCustomAlert("请在 Tampermonkey 菜单中设置 TTS API 的基础 URL。");
  179. return;
  180. }
  181. if (!apiKey) {
  182. showCustomAlert("请在 Tampermonkey 菜单中设置 TTS API 的 API 密钥。");
  183. return;
  184. }
  185. setLoadingState(); // 设置按钮为加载状态
  186. if (window.location.hostname === "github.com") {
  187. speakText(selectedText);
  188. resetButton(); // 使用内建 TTS 后立即重置按钮
  189. } else {
  190. callOpenAITTS(selectedText, baseUrl, apiKey, voice, model);
  191. }
  192. } else {
  193. showCustomAlert("请选择一些有效的文本以朗读。");
  194. }
  195. });
  196.  
  197. // 在选中文本附近显示按钮
  198. document.addEventListener("mouseup", (event) => {
  199. // 设置一个短暂的延迟,确保选区状态已更新
  200. setTimeout(() => {
  201. // 检查 mouseup 事件是否由按钮本身触发
  202. if (event.target === button) {
  203. return;
  204. }
  205.  
  206. const selectedText = getSelectedText();
  207. if (selectedText && isTextValid(selectedText)) {
  208. // 添加有效性检查
  209. const mouseX = event.pageX;
  210. const mouseY = event.pageY;
  211. button.style.left = `${mouseX + 30}px`; // 调整按钮位置
  212. button.style.top = `${mouseY - 10}px`;
  213. button.style.display = "block";
  214. } else {
  215. button.style.display = "none";
  216. }
  217. }, 10); // 10毫秒延迟
  218. });
  219.  
  220. // 监听点击页面其他部分以隐藏按钮
  221. document.addEventListener("click", (event) => {
  222. if (event.target !== button) {
  223. const selectedText = getSelectedText();
  224. if (!selectedText || !isTextValid(selectedText)) {
  225. button.style.display = "none";
  226. }
  227. }
  228. });
  229.  
  230. // 初始化配置模态框
  231. function initModal() {
  232. const modalHTML = `
  233. <div id="configModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: none; justify-content: center; align-items: center; z-index: 10000;">
  234. <div style="background: white; padding: 20px; border-radius: 10px; width: 300px;">
  235. <h2>配置 TTS 设置</h2>
  236. <label for="baseUrl">基础 URL:</label>
  237. <input type="text" id="baseUrl" value="${GM_getValue(
  238. "baseUrl",
  239. "https://api.openai.com"
  240. )}" style="width: 100%;">
  241. <label for="apiKey">API 密钥:</label>
  242. <input type="text" id="apiKey" value="${GM_getValue(
  243. "apiKey",
  244. ""
  245. )}" style="width: 100%;">
  246. <label for="model">模型:</label>
  247. <select id="model" style="width: 100%;">
  248. <option value="tts-1">tts-1</option>
  249. <option value="tts-hailuo">tts-hailuo</option>
  250. <option value="tts-1-hd">tts-1-hd</option>
  251. <option vlaue="tts-audio-fish">tts-audio-fish</option>
  252. </select>
  253. <label for="voice">语音:</label>
  254. <select id="voice" style="width: 100%;">
  255. <option value="alloy">Alloy</option>
  256. <option value="echo">Echo</option>
  257. <option value="fable">Fable</option>
  258. <option value="onyx">Onyx</option>
  259. <option value="nova">Nova</option>
  260. <option value="shimmer">Shimmer</option>
  261. </select>
  262. <button id="saveConfig" style="margin-top: 10px; width: 100%; padding: 5px; background-color: #007BFF; color: white; border: none; border-radius: 3px;">保存</button>
  263. <button id="cancelConfig" style="margin-top: 5px; width: 100%; padding: 5px; background-color: grey; color: white; border: none; border-radius: 3px;">取消</button>
  264. </div>
  265. </div>
  266. `;
  267. document.body.insertAdjacentHTML("beforeend", modalHTML);
  268. document.getElementById("saveConfig").addEventListener("click", saveConfig);
  269. document
  270. .getElementById("cancelConfig")
  271. .addEventListener("click", closeModal);
  272. document
  273. .getElementById("model")
  274. .addEventListener("change", updateVoiceOptions);
  275. }
  276.  
  277. // 根据选择的模型更新语音选项
  278. function updateVoiceOptions() {
  279. const modelSelect = document.getElementById("model");
  280. const voiceSelect = document.getElementById("voice");
  281.  
  282. if (modelSelect.value === "tts-hailuo") {
  283. voiceSelect.innerHTML = `
  284. <option value="male-botong">思远</option>
  285. <option value="Podcast_girl">心悦</option>
  286. <option value="boyan_new_hailuo">子轩</option>
  287. <option value="female-shaonv">灵儿</option>
  288. <option value="YaeMiko_hailuo">语嫣</option>
  289. <option value="xiaoyi_mix_hailuo">少泽</option>
  290. <option value="xiaomo_sft">芷溪</option>
  291. <option value="cove_test2_hailuo">浩翔(英文)</option>
  292. <option value="scarlett_hailuo">雅涵(英文)</option>
  293. <option value="Leishen2_hailuo">雷电将军</option>
  294. <option value="Zhongli_hailuo">钟离</option>
  295. <option value="Paimeng_hailuo">派蒙</option>
  296. <option value="keli_hailuo">可莉</option>
  297. <option value="Hutao_hailuo">胡桃</option>
  298. <option value="Xionger_hailuo">熊二</option>
  299. <option value="Haimian_hailuo">海绵宝宝</option>
  300. <option value="Robot_hunter_hailuo">变形金刚</option>
  301. <option value="Linzhiling_hailuo">小玲玲</option>
  302. <option value="huafei_hailuo">拽妃</option>
  303. <option value="lingfeng_hailuo">东北er</option>
  304. <option value="male_dongbei_hailuo">老铁</option>
  305. <option value="Beijing_hailuo">北京er</option>
  306. <option value="JayChou_hailuo">JayChou</option>
  307. <option value="Daniel_hailuo">潇然</option>
  308. <option value="Bingjiao_zongcai_hailuo">沉韵</option>
  309. <option value="female-yaoyao-hd">瑶瑶</option>
  310. <option value="murong_sft">晨曦</option>
  311. <option value="shangshen_sft">沐珊</option>
  312. <option value="kongchen_sft">祁辰</option>
  313. <option value="shenteng2_hailuo">夏洛特</option>
  314. <option value="Guodegang_hailuo">郭嘚嘚</option>
  315. <option value="yueyue_hailuo">小月月</option>
  316. `;
  317. } else if (modelSelect.value === "tts-1-hd") {
  318. voiceSelect.innerHTML = `
  319. <option value="alloy">Alloy</option>
  320. <option value="echo">Echo</option>
  321. <option value="fable">Fable</option>
  322. <option value="onyx">Onyx</option>
  323. <option value="nova">Nova</option>
  324. <option value="shimmer">Shimmer</option>
  325. `;
  326. } else if (modelSelect.value === "tts-audio-fish") {
  327. voiceSelect.innerHTML = `
  328. <option value="54a5170264694bfc8e9ad98df7bd89c3">丁真</option>
  329. <option value="7f92f8afb8ec43bf81429cc1c9199cb1">AD学姐</option>
  330. <option value="0eb38bc974e1459facca38b359e13511">赛马娘</option>
  331. <option value="e4642e5edccd4d9ab61a69e82d4f8a14">蔡徐坤</option>
  332. <option value="332941d1360c48949f1b4e0cabf912cd">丁真(锐刻五代版)</option>
  333. <option value="f7561ff309bd4040a59f1e600f4f4338">黑手</option>
  334. <option value="e80ea225770f42f79d50aa98be3cedfc">孙笑川258</option>
  335. <option value="1aacaeb1b840436391b835fd5513f4c4">芙宁娜</option>
  336. <option value="59cb5986671546eaa6ca8ae6f29f6d22">央视配音</option>
  337. <option value="3b55b3d84d2f453a98d8ca9bb24182d6">邓紫琪</option>
  338. <option value="738d0cc1a3e9430a9de2b544a466a7fc">雷军</option>
  339. <option value="e1cfccf59a1c4492b5f51c7c62a8abd2">永雏塔菲</option>
  340. <option value="7af4d620be1c4c6686132f21940d51c5">东雪莲</option>
  341. <option value="7c66db6e457c4d53b1fe428a8c547953">郭德纲</option>
  342. <option value="e488ebeadd83496b97a3cd472dcd04ab">爱丽丝(中配)</option>
  343. <option value="b1ce0a88c79f4e3180217a7fe2c72969">飞凡高启强</option>
  344. <option value="57a14f36492d4d0eb207b9fe9d335f95">国恒</option>
  345. <option value="787159b6d13542afbaff4f933689bab6">伯邑考</option>
  346. <option value="f4913edba8844da9827c28210ff5f884">机智张</option>
  347. <option value="c1fc72257200410587a557758b320700">彭海兵</option>
  348. <option value="8a112f7f56694daaa3c7a55c08f6e5a0">申公豹</option>
  349. <option value="af450a74e5f94095bbf009e2c7b6b0e7">赵德汉</option>
  350. <option value="b1602dc301a84093aabe97da41e59ee7">神魔暗信</option>
  351. <option value="de5e904b61214ed5bad3e4757cd5aed9">诸葛</option>
  352. `;
  353. } else {
  354. // 恢复默认选项
  355. voiceSelect.innerHTML = `
  356. <option value="alloy">Alloy</option>
  357. <option value="echo">Echo</option>
  358. <option value="fable">Fable</option>
  359. <option value="onyx">Onyx</option>
  360. <option value="nova">Nova</option>
  361. <option value="shimmer">Shimmer</option>
  362. `;
  363. }
  364. }
  365.  
  366. // 保存配置
  367. function saveConfig() {
  368. const baseUrl = document.getElementById("baseUrl").value.trim();
  369. const model = document.getElementById("model").value;
  370. const apiKey = document.getElementById("apiKey").value.trim();
  371. const voice = document.getElementById("voice").value;
  372.  
  373. if (!baseUrl) {
  374. showCustomAlert("基础 URL 不能为空。");
  375. return;
  376. }
  377.  
  378. if (!apiKey) {
  379. showCustomAlert("API 密钥不能为空。");
  380. return;
  381. }
  382.  
  383. GM_setValue("baseUrl", baseUrl);
  384. GM_setValue("model", model);
  385. GM_setValue("apiKey", apiKey);
  386. GM_setValue("voice", voice);
  387. showCustomAlert("设置已成功保存。");
  388. closeModal();
  389. }
  390.  
  391. // 关闭模态框
  392. function closeModal() {
  393. if (document.getElementById("configModal")) {
  394. document.getElementById("configModal").style.display = "none";
  395. }
  396. }
  397.  
  398. // 打开模态框
  399. function openModal() {
  400. if (!document.getElementById("configModal")) {
  401. initModal();
  402. }
  403. document.getElementById("configModal").style.display = "flex";
  404. // 设置当前值
  405. document.getElementById("baseUrl").value = GM_getValue(
  406. "baseUrl",
  407. "https://api.openai.com"
  408. );
  409. document.getElementById("apiKey").value = GM_getValue("apiKey", "");
  410. document.getElementById("model").value = GM_getValue("model", "tts-1");
  411. updateVoiceOptions(); // 根据模型更新语音选项
  412. document.getElementById("voice").value = GM_getValue("voice", "onyx");
  413. }
  414.  
  415. // 创建自定义弹窗
  416. function createCustomAlert() {
  417. const alertBox = document.createElement("div");
  418. alertBox.id = "customAlertBox";
  419. alertBox.style.cssText = `
  420. position: fixed;
  421. top: 50%;
  422. left: 50%;
  423. transform: translate(-50%, -50%);
  424. background-color: white;
  425. padding: 20px;
  426. border-radius: 5px;
  427. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  428. z-index: 2147483647; // 使用最高的 z-index 值
  429. display: none;
  430. color: #333; // 设置默认文字颜色
  431. font-family: Arial, sans-serif; // 设置字体
  432. max-width: 80%;
  433. width: 300px;
  434. text-align: center;
  435. `;
  436.  
  437. const message = document.createElement("p");
  438. message.id = "alertMessage";
  439. message.style.cssText = `
  440. margin-bottom: 15px;
  441. color: #333; // 确保消息文本颜色
  442. word-wrap: break-word;
  443. `;
  444.  
  445. const closeButton = document.createElement("button");
  446. closeButton.textContent = "确定";
  447. closeButton.style.cssText = `
  448. padding: 5px 10px;
  449. background-color: #007BFF;
  450. color: white;
  451. border: none;
  452. border-radius: 3px;
  453. cursor: pointer;
  454. font-family: inherit; // 继承父元素的字体
  455. `;
  456. closeButton.onclick = () => {
  457. alertBox.style.opacity = "0";
  458. setTimeout(() => (alertBox.style.display = "none"), 300);
  459. };
  460.  
  461. alertBox.appendChild(message);
  462. alertBox.appendChild(closeButton);
  463. document.body.appendChild(alertBox);
  464. // 添加淡入淡出效果
  465. alertBox.style.transition = "opacity 0.3s ease-in-out";
  466. }
  467.  
  468. // 显示自定义弹窗
  469. function showCustomAlert(text) {
  470. const alertBox =
  471. document.getElementById("customAlertBox") || createCustomAlert();
  472. document.getElementById("alertMessage").textContent = text;
  473. alertBox.style.display = "block";
  474. alertBox.style.opacity = "0";
  475. setTimeout(() => (alertBox.style.opacity = "1"), 10); // 短暂延迟以确保过渡效果生效
  476. }
  477.  
  478. // 注册菜单命令以打开配置
  479. GM_registerMenuCommand("配置 TTS 设置", openModal);
  480.  
  481. // 注册菜单命令以清除缓存
  482. GM_registerMenuCommand("清除 TTS 缓存", clearCache);
  483. })();