Text-to-Speech Reader

Read selected text using OpenAI TTS API

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==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);
})();