Greasy Fork is available in English.

Text-to-Speech Reader

Read selected text using OpenAI TTS API

// ==UserScript==
// @name         Text-to-Speech Reader
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Read selected text using OpenAI TTS API
// @author       https://linux.do/u/snaily,https://linux.do/u/joegodwanggod
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // 创建按钮
  const button = document.createElement("button");
  button.innerText = "TTS";
  button.style.position = "absolute";
  button.style.width = "auto";
  button.style.zIndex = "1000";
  button.style.display = "none"; // 初始隐藏
  button.style.backgroundColor = "#007BFF"; // 蓝色背景
  button.style.color = "#FFFFFF"; // 白色文字
  button.style.border = "none";
  button.style.borderRadius = "3px"; // 调整圆角
  button.style.padding = "5px 10px"; // 减少内边距
  button.style.boxShadow = "0 2px 5px rgba(0, 0, 0, 0.2)";
  button.style.cursor = "pointer";
  button.style.fontSize = "12px";
  button.style.fontFamily = "Arial, sans-serif";
  document.body.appendChild(button);

  // 获取选中的文本
  function getSelectedText() {
    let text = "";
    if (window.getSelection) {
      text = window.getSelection().toString();
    } else if (document.selection && document.selection.type != "Control") {
      text = document.selection.createRange().text;
    }
    console.log("Selected Text:", text); // 调试用
    return text;
  }

  // 判断文本是否为有效内容 (非空白)
  function isTextValid(text) {
    return text.trim().length > 0;
  }

  // 调用 OpenAI TTS API
  function callOpenAITTS(text, baseUrl, apiKey, voice, model) {
    const cachedAudioUrl = getCachedAudio(text);
    if (cachedAudioUrl) {
      console.log("使用缓存的音频");
      playAudio(cachedAudioUrl);
      resetButton();
      return;
    }

    const url = `${baseUrl}/v1/audio/speech`;
    console.log("调用 OpenAI TTS API,文本:", text);
    GM_xmlhttpRequest({
      method: "POST",
      url: url,
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${apiKey}`,
      },
      data: JSON.stringify({
        model: model,
        input: text,
        voice: voice,
      }),
      responseType: "arraybuffer",
      onload: function (response) {
        if (response.status === 200) {
          console.log("API 调用成功"); // 调试用
          const audioBlob = new Blob([response.response], {
            type: "audio/mpeg",
          });
          const audioUrl = URL.createObjectURL(audioBlob);
          playAudio(audioUrl);
          cacheAudio(text, audioUrl);
        } else {
          console.error("错误:", response.statusText);
          showCustomAlert(
            `TTS API 错误:${response.status} ${response.statusText}`
          );
        }
        // 请求完成后重置按钮
        resetButton();
      },
      onerror: function (error) {
        console.error("请求失败", error);
        showCustomAlert("TTS API 请求失败。");
        // 请求失败后重置按钮
        resetButton();
      },
    });
  }

  // 播放音频
  function playAudio(url) {
    const audio = new Audio(url);
    audio.play();
  }

  // 使用浏览器内建 TTS
  function speakText(text) {
    const utterance = new SpeechSynthesisUtterance(text);
    speechSynthesis.speak(utterance);
  }

  // 设置按钮为加载状态
  function setLoadingState() {
    button.disabled = true;
    button.innerText = "Loading";
    button.style.backgroundColor = "#6c757d"; // 灰色背景
    button.style.cursor = "not-allowed";
  }

  // 重置按钮到原始状态
  function resetButton() {
    button.disabled = false;
    button.innerText = "TTS";
    button.style.backgroundColor = "#007BFF"; // 蓝色背景
    button.style.cursor = "pointer";
  }

  // 获取缓存的音频 URL
  function getCachedAudio(text) {
    const cache = GM_getValue("cache", {});
    const item = cache[text];
    if (item) {
      const now = new Date().getTime();
      const weekInMillis = 7 * 24 * 60 * 60 * 1000; // 一周的毫秒数
      if (now - item.timestamp < weekInMillis) {
        return item.audioUrl;
      } else {
        delete cache[text]; // 删除过期的缓存
        GM_setValue("cache", cache);
      }
    }
    return null;
  }

  // 缓存音频 URL
  function cacheAudio(text, audioUrl) {
    const cache = GM_getValue("cache", {});
    cache[text] = {
      audioUrl: audioUrl,
      timestamp: new Date().getTime(),
    };
    GM_setValue("cache", cache);
  }

  // 清除缓存
  function clearCache() {
    GM_setValue("cache", {});
    showCustomAlert("缓存已成功清除。");
  }

  // 按钮点击事件
  button.addEventListener("click", (event) => {
    event.stopPropagation(); // 防止点击按钮时触发全局点击事件
    const selectedText = getSelectedText();
    if (selectedText && isTextValid(selectedText)) {
      // 添加有效性检查
      let apiKey = GM_getValue("apiKey", null);
      let baseUrl = GM_getValue("baseUrl", null);
      let voice = GM_getValue("voice", "onyx"); // 默认为 'onyx'
      let model = GM_getValue("model", "tts-1"); // 默认为 'tts-1'
      if (!baseUrl) {
        showCustomAlert("请在 Tampermonkey 菜单中设置 TTS API 的基础 URL。");
        return;
      }
      if (!apiKey) {
        showCustomAlert("请在 Tampermonkey 菜单中设置 TTS API 的 API 密钥。");
        return;
      }
      setLoadingState(); // 设置按钮为加载状态
      if (window.location.hostname === "github.com") {
        speakText(selectedText);
        resetButton(); // 使用内建 TTS 后立即重置按钮
      } else {
        callOpenAITTS(selectedText, baseUrl, apiKey, voice, model);
      }
    } else {
      showCustomAlert("请选择一些有效的文本以朗读。");
    }
  });

  // 在选中文本附近显示按钮
  document.addEventListener("mouseup", (event) => {
    // 设置一个短暂的延迟,确保选区状态已更新
    setTimeout(() => {
      // 检查 mouseup 事件是否由按钮本身触发
      if (event.target === button) {
        return;
      }

      const selectedText = getSelectedText();
      if (selectedText && isTextValid(selectedText)) {
        // 添加有效性检查
        const mouseX = event.pageX;
        const mouseY = event.pageY;
        button.style.left = `${mouseX + 30}px`; // 调整按钮位置
        button.style.top = `${mouseY - 10}px`;
        button.style.display = "block";
      } else {
        button.style.display = "none";
      }
    }, 10); // 10毫秒延迟
  });

  // 监听点击页面其他部分以隐藏按钮
  document.addEventListener("click", (event) => {
    if (event.target !== button) {
      const selectedText = getSelectedText();
      if (!selectedText || !isTextValid(selectedText)) {
        button.style.display = "none";
      }
    }
  });

  // 初始化配置模态框
  function initModal() {
    const modalHTML = `
          <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;">
              <div style="background: white; padding: 20px; border-radius: 10px; width: 300px;">
                  <h2>配置 TTS 设置</h2>
                  <label for="baseUrl">基础 URL:</label>
                  <input type="text" id="baseUrl" value="${GM_getValue(
                    "baseUrl",
                    "https://api.openai.com"
                  )}" style="width: 100%;">
                  <label for="apiKey">API 密钥:</label>
                  <input type="text" id="apiKey" value="${GM_getValue(
                    "apiKey",
                    ""
                  )}" style="width: 100%;">
                  <label for="model">模型:</label>
                  <select id="model" style="width: 100%;">
                      <option value="tts-1">tts-1</option>
                      <option value="tts-hailuo">tts-hailuo</option>
                      <option value="tts-1-hd">tts-1-hd</option>
                      <option vlaue="tts-audio-fish">tts-audio-fish</option>
                  </select>
                  <label for="voice">语音:</label>
                  <select id="voice" style="width: 100%;">
                      <option value="alloy">Alloy</option>
                      <option value="echo">Echo</option>
                      <option value="fable">Fable</option>
                      <option value="onyx">Onyx</option>
                      <option value="nova">Nova</option>
                      <option value="shimmer">Shimmer</option>
                  </select>
                  <button id="saveConfig" style="margin-top: 10px; width: 100%; padding: 5px; background-color: #007BFF; color: white; border: none; border-radius: 3px;">保存</button>
                  <button id="cancelConfig" style="margin-top: 5px; width: 100%; padding: 5px; background-color: grey; color: white; border: none; border-radius: 3px;">取消</button>
              </div>
          </div>
      `;
    document.body.insertAdjacentHTML("beforeend", modalHTML);
    document.getElementById("saveConfig").addEventListener("click", saveConfig);
    document
      .getElementById("cancelConfig")
      .addEventListener("click", closeModal);
    document
      .getElementById("model")
      .addEventListener("change", updateVoiceOptions);
  }

  // 根据选择的模型更新语音选项
  function updateVoiceOptions() {
    const modelSelect = document.getElementById("model");
    const voiceSelect = document.getElementById("voice");

    if (modelSelect.value === "tts-hailuo") {
      voiceSelect.innerHTML = `
              <option value="male-botong">思远</option>
              <option value="Podcast_girl">心悦</option>
              <option value="boyan_new_hailuo">子轩</option>
              <option value="female-shaonv">灵儿</option>
              <option value="YaeMiko_hailuo">语嫣</option>
              <option value="xiaoyi_mix_hailuo">少泽</option>
              <option value="xiaomo_sft">芷溪</option>
              <option value="cove_test2_hailuo">浩翔(英文)</option>
              <option value="scarlett_hailuo">雅涵(英文)</option>
              <option value="Leishen2_hailuo">雷电将军</option>
              <option value="Zhongli_hailuo">钟离</option>
              <option value="Paimeng_hailuo">派蒙</option>
              <option value="keli_hailuo">可莉</option>
              <option value="Hutao_hailuo">胡桃</option>
              <option value="Xionger_hailuo">熊二</option>
              <option value="Haimian_hailuo">海绵宝宝</option>
              <option value="Robot_hunter_hailuo">变形金刚</option>
              <option value="Linzhiling_hailuo">小玲玲</option>
              <option value="huafei_hailuo">拽妃</option>
              <option value="lingfeng_hailuo">东北er</option>
              <option value="male_dongbei_hailuo">老铁</option>
              <option value="Beijing_hailuo">北京er</option>
              <option value="JayChou_hailuo">JayChou</option>
              <option value="Daniel_hailuo">潇然</option>
              <option value="Bingjiao_zongcai_hailuo">沉韵</option>
              <option value="female-yaoyao-hd">瑶瑶</option>
              <option value="murong_sft">晨曦</option>
              <option value="shangshen_sft">沐珊</option>
              <option value="kongchen_sft">祁辰</option>
              <option value="shenteng2_hailuo">夏洛特</option>
              <option value="Guodegang_hailuo">郭嘚嘚</option>
              <option value="yueyue_hailuo">小月月</option>
          `;
    } else if (modelSelect.value === "tts-1-hd") {
      voiceSelect.innerHTML = `
              <option value="alloy">Alloy</option>
              <option value="echo">Echo</option>
              <option value="fable">Fable</option>
              <option value="onyx">Onyx</option>
              <option value="nova">Nova</option>
              <option value="shimmer">Shimmer</option>
          `;
    } else if (modelSelect.value === "tts-audio-fish") {
      voiceSelect.innerHTML = `
              <option value="54a5170264694bfc8e9ad98df7bd89c3">丁真</option>
              <option value="7f92f8afb8ec43bf81429cc1c9199cb1">AD学姐</option>
              <option value="0eb38bc974e1459facca38b359e13511">赛马娘</option>
              <option value="e4642e5edccd4d9ab61a69e82d4f8a14">蔡徐坤</option>
              <option value="332941d1360c48949f1b4e0cabf912cd">丁真(锐刻五代版)</option>
              <option value="f7561ff309bd4040a59f1e600f4f4338">黑手</option>
              <option value="e80ea225770f42f79d50aa98be3cedfc">孙笑川258</option>
              <option value="1aacaeb1b840436391b835fd5513f4c4">芙宁娜</option>
              <option value="59cb5986671546eaa6ca8ae6f29f6d22">央视配音</option>
              <option value="3b55b3d84d2f453a98d8ca9bb24182d6">邓紫琪</option>
              <option value="738d0cc1a3e9430a9de2b544a466a7fc">雷军</option>
              <option value="e1cfccf59a1c4492b5f51c7c62a8abd2">永雏塔菲</option>
              <option value="7af4d620be1c4c6686132f21940d51c5">东雪莲</option>
              <option value="7c66db6e457c4d53b1fe428a8c547953">郭德纲</option>
              <option value="e488ebeadd83496b97a3cd472dcd04ab">爱丽丝(中配)</option>
              <option value="b1ce0a88c79f4e3180217a7fe2c72969">飞凡高启强</option>
              <option value="57a14f36492d4d0eb207b9fe9d335f95">国恒</option>
              <option value="787159b6d13542afbaff4f933689bab6">伯邑考</option>
              <option value="f4913edba8844da9827c28210ff5f884">机智张</option>
              <option value="c1fc72257200410587a557758b320700">彭海兵</option>
              <option value="8a112f7f56694daaa3c7a55c08f6e5a0">申公豹</option>
              <option value="af450a74e5f94095bbf009e2c7b6b0e7">赵德汉</option>
              <option value="b1602dc301a84093aabe97da41e59ee7">神魔暗信</option>
              <option value="de5e904b61214ed5bad3e4757cd5aed9">诸葛</option>
          `;
    } else {
      // 恢复默认选项
      voiceSelect.innerHTML = `
              <option value="alloy">Alloy</option>
              <option value="echo">Echo</option>
              <option value="fable">Fable</option>
              <option value="onyx">Onyx</option>
              <option value="nova">Nova</option>
              <option value="shimmer">Shimmer</option>
          `;
    }
  }

  // 保存配置
  function saveConfig() {
    const baseUrl = document.getElementById("baseUrl").value.trim();
    const model = document.getElementById("model").value;
    const apiKey = document.getElementById("apiKey").value.trim();
    const voice = document.getElementById("voice").value;

    if (!baseUrl) {
      showCustomAlert("基础 URL 不能为空。");
      return;
    }

    if (!apiKey) {
      showCustomAlert("API 密钥不能为空。");
      return;
    }

    GM_setValue("baseUrl", baseUrl);
    GM_setValue("model", model);
    GM_setValue("apiKey", apiKey);
    GM_setValue("voice", voice);
    showCustomAlert("设置已成功保存。");
    closeModal();
  }

  // 关闭模态框
  function closeModal() {
    if (document.getElementById("configModal")) {
      document.getElementById("configModal").style.display = "none";
    }
  }

  // 打开模态框
  function openModal() {
    if (!document.getElementById("configModal")) {
      initModal();
    }
    document.getElementById("configModal").style.display = "flex";
    // 设置当前值
    document.getElementById("baseUrl").value = GM_getValue(
      "baseUrl",
      "https://api.openai.com"
    );
    document.getElementById("apiKey").value = GM_getValue("apiKey", "");
    document.getElementById("model").value = GM_getValue("model", "tts-1");
    updateVoiceOptions(); // 根据模型更新语音选项
    document.getElementById("voice").value = GM_getValue("voice", "onyx");
  }

  // 创建自定义弹窗
  function createCustomAlert() {
    const alertBox = document.createElement("div");
    alertBox.id = "customAlertBox";
    alertBox.style.cssText = `
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: white;
    padding: 20px;
    border-radius: 5px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
    z-index: 2147483647; // 使用最高的 z-index 值
    display: none;
    color: #333; // 设置默认文字颜色
    font-family: Arial, sans-serif; // 设置字体
    max-width: 80%;
    width: 300px;
    text-align: center;
  `;

    const message = document.createElement("p");
    message.id = "alertMessage";
    message.style.cssText = `
    margin-bottom: 15px;
    color: #333; // 确保消息文本颜色
    word-wrap: break-word;
  `;

    const closeButton = document.createElement("button");
    closeButton.textContent = "确定";
    closeButton.style.cssText = `
    padding: 5px 10px;
    background-color: #007BFF;
    color: white;
    border: none;
    border-radius: 3px;
    cursor: pointer;
    font-family: inherit; // 继承父元素的字体
  `;
    closeButton.onclick = () => {
      alertBox.style.opacity = "0";
      setTimeout(() => (alertBox.style.display = "none"), 300);
    };

    alertBox.appendChild(message);
    alertBox.appendChild(closeButton);
    document.body.appendChild(alertBox);
    // 添加淡入淡出效果
    alertBox.style.transition = "opacity 0.3s ease-in-out";
  }

  // 显示自定义弹窗
  function showCustomAlert(text) {
    const alertBox =
      document.getElementById("customAlertBox") || createCustomAlert();
    document.getElementById("alertMessage").textContent = text;
    alertBox.style.display = "block";
    alertBox.style.opacity = "0";
    setTimeout(() => (alertBox.style.opacity = "1"), 10); // 短暂延迟以确保过渡效果生效
  }

  // 注册菜单命令以打开配置
  GM_registerMenuCommand("配置 TTS 设置", openModal);

  // 注册菜单命令以清除缓存
  GM_registerMenuCommand("清除 TTS 缓存", clearCache);
})();