// ==UserScript== // @name 在线听书助手 🎧(WorkBuddy) // @namespace https://github.com/ebook-tts // @version 1.0.7 // @description 在线阅读时朗读网页内容,支持本地系统音色 + 火山引擎 / 腾讯云 TTS 网络音色、语速/音调/音量调节、悬浮球控制面板。快捷键:Alt+S 播放/暂停,Alt+Q 停止,Alt+←/→ 上/下一段,Alt+H 唤回面板。 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @connect openspeech.bytedance.com // @connect tts.tencentcloudapi.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 火山引擎 TTS 配置(在面板中填写,无需改代码) // 控制台:https://console.volcengine.com/speech/service/8 // ===================================================== const VOLCANO_CFG = { appid: GM_getValue('volcano_appid', ''), token: GM_getValue('volcano_token', ''), cluster: GM_getValue('volcano_cluster', 'volcano_tts'), }; // ===================================================== // 腾讯云 TTS 配置(在面板中填写,无需改代码) // 控制台:https://console.cloud.tencent.com/tts // ===================================================== const TENCENT_CFG = { secretId: GM_getValue('tencent_secretId', ''), secretKey: GM_getValue('tencent_secretKey', ''), }; // ===================================================== // 腾讯云 TTS 音色列表(基础语音合成接口) // 完整列表:https://cloud.tencent.com/document/product/1073/92668 // ===================================================== const TENCENT_VOICES = [ // ── 超自然大模型音色 ── { id: 502003, name: '智小敏(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502001, name: '智小柔(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502005, name: '智小解(超自然·解说男声)', group: '✨ 超自然大模型' }, { id: 502006, name: '智小悟(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603004, name: '温柔小柠(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603003, name: '随和老李(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603007, name: '邻家女孩(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603006, name: '沉稳青叔(超自然·聊天男声)', group: '✨ 超自然大模型' }, // ── 大模型音色 ── { id: 501000, name: '智斌(大模型·阅读男声)', group: '🤖 大模型音色' }, { id: 501002, name: '智菊(大模型·阅读女声)', group: '🤖 大模型音色' }, { id: 501001, name: '智兰(大模型·资讯女声)', group: '🤖 大模型音色' }, { id: 601008, name: '爱小豪(大模型·多情感男声)', group: '🤖 大模型音色' }, { id: 601009, name: '爱小芊(大模型·多情感女声)', group: '🤖 大模型音色' }, { id: 601013, name: '爱小伊(大模型·阅读女声)', group: '🤖 大模型音色' }, // ── 精品音色 ── { id: 101001, name: '智瑜(精品·情感女声)', group: '💎 精品音色' }, { id: 101004, name: '智云(精品·通用男声)', group: '💎 精品音色' }, { id: 101011, name: '智燕(精品·新闻女声)', group: '💎 精品音色' }, { id: 101013, name: '智辉(精品·新闻男声)', group: '💎 精品音色' }, { id: 101019, name: '智彤(精品·粤语女声)', group: '💎 精品音色' }, { id: 101016, name: '智甜(精品·女童声)', group: '💎 精品音色' }, { id: 101015, name: '智萌(精品·男童声)', group: '💎 精品音色' }, ]; // ===================================================== // 音色中文名称映射表(本地系统音色) // ===================================================== const VOICE_ZH_NAME = { 'Microsoft Huihui': '微软 慧慧(普通话·女)', 'Microsoft Kangkang': '微软 康康(普通话·男)', 'Microsoft Yaoyao': '微软 瑶瑶(普通话·女童)', 'Microsoft Tracy': '微软 晓晓(粤语·女)', 'Microsoft Danny': '微软 丹尼(粤语·男)', 'Microsoft HiuGaai': '微软 晓佳(粤语·女)', 'Microsoft HiuMaan': '微软 晓曼(粤语·女)', 'Microsoft WanLung': '微软 云龙(粤语·男)', 'Microsoft Xiaoxiao': '微软 晓晓(普通话·女)', 'Microsoft Yunxi': '微软 云希(普通话·男)', 'Microsoft Yunjian': '微软 云健(普通话·男)', 'Microsoft Xiaochen': '微软 晓辰(普通话·女)', 'Microsoft Xiaohan': '微软 晓涵(普通话·女)', 'Microsoft Xiaomeng': '微软 晓梦(普通话·女)', 'Microsoft Xiaomo': '微软 晓墨(普通话·女)', 'Microsoft Xiaoqiu': '微软 晓秋(普通话·女)', 'Microsoft Xiaorui': '微软 晓睿(普通话·女)', 'Microsoft Xiaoshuang': '微软 晓双(普通话·女童)', 'Microsoft Xiaoyan': '微软 晓颜(普通话·女)', 'Microsoft Xiaoyou': '微软 晓悠(普通话·女童)', 'Microsoft Xiaozhen': '微软 晓甄(普通话·女)', 'Microsoft Yunfeng': '微软 云枫(普通话·男)', 'Microsoft Yunhao': '微软 云皓(普通话·男)', 'Microsoft Yunxia': '微软 云夏(普通话·男)', 'Microsoft Yunyang': '微软 云扬(普通话·男)', 'Microsoft Yunye': '微软 云野(普通话·男)', 'Microsoft Yunze': '微软 云泽(普通话·男)', 'Ting-Ting': '婷婷(普通话·女)', 'Sin-ji': '善慈(粤语·女)', 'Mei-Jia': '美佳(闽南话·女)', 'Yu-shu': '玉书(台湾·女)', 'Google 普通话(中国大陆)': '谷歌 普通话(大陆·女)', 'Google 粤語(香港)': '谷歌 粤语(香港·女)', 'Google 國語(臺灣)': '谷歌 国语(台湾·女)', 'Google 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 火山引擎 TTS 网络音色列表 // 更多音色:https://www.volcengine.com/docs/6561/97465 // ===================================================== const ONLINE_VOICES = [ // ── 普通话·女声 ── { id: 'zh_female_shuangkuaisisi_moon_bigtts', name: '爽快思思(普通话·女·活泼)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_tianmeixiaoyuan_moon_bigtts', name: '甜美小源(普通话·女·甜美)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_qingxinnvsheng_moon_bigtts', name: '清新女生(普通话·女·清新)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_wenrouxiaoya_moon_bigtts', name: '温柔小雅(普通话·女·温柔)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_linjiatong_moon_bigtts', name: '邻家童(普通话·女童·可爱)', group: '🇨🇳 普通话·女声' }, // ── 普通话·男声 ── { id: 'zh_male_qingshuang_moon_bigtts', name: '清爽男声(普通话·男·自然)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_zhubo_moon_bigtts', name: '专业主播(普通话·男·播音)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_chunhou_moon_bigtts', name: '醇厚男声(普通话·男·低沉)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_dongfang_moon_bigtts', name: '东方男声(普通话·男·磁性)', group: '🇨🇳 普通话·男声' }, // ── 有声书专区 ── { id: 'zh_female_story_moon_bigtts', name: '故事女生(有声书·温情)', group: '📖 有声书专区' }, { id: 'zh_male_story_moon_bigtts', name: '故事男生(有声书·磁性)', group: '📖 有声书专区' }, { id: 'zh_female_yuanshen_moon_bigtts', name: '元神女声(有声书·悬疑)', group: '📖 有声书专区' }, // ── 方言 ── { id: 'zh_female_cantonese_qingxin_bigmodel', name: '粤语·清新女声', group: '🗣️ 方言' }, { id: 'zh_male_cantonese_chunhou_bigmodel', name: '粤语·醇厚男声', group: '🗣️ 方言' }, ]; // ===================================================== // 状态管理 // ===================================================== const state = { isPlaying: false, isPaused: false, utterance: null, audioEl: null, paragraphs: [], currentIndex: 0, voices: [], selectedVoiceId: GM_getValue('selectedVoiceId', ''), rate: parseFloat(GM_getValue('rate', '1.0')), pitch: parseFloat(GM_getValue('pitch', '1.0')), volume: parseFloat(GM_getValue('volume', '1.0')), highlightEl: null, panelOpen: false, voiceMode: GM_getValue('voiceMode', 'local'), // 'local' | 'volcano' | 'tencent' onlineAbort: null, }; // ===================================================== // 样式注入 // ===================================================== GM_addStyle(` #erp-fab { position: fixed !important; bottom: 28px !important; right: 28px !important; z-index: 2147483646 !important; width: 52px !important; height: 52px !important; border-radius: 50% !important; background: linear-gradient(135deg, #7c4dff, #e040fb) !important; box-shadow: 0 4px 20px rgba(124,77,255,0.55) !important; cursor: pointer !important; border: none !important; outline: none !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 24px !important; user-select: none !important; transition: transform 0.2s, box-shadow 0.2s !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; } #erp-fab:hover { transform: scale(1.1) !important; box-shadow: 0 6px 28px rgba(124,77,255,0.7) !important; } #erp-fab:active { transform: scale(0.95) !important; } #erp-fab .erp-fab-dot { position: absolute !important; top: 3px !important; right: 3px !important; width: 10px !important; height: 10px !important; border-radius: 50% !important; border: 2px solid #fff !important; background: #4caf50 !important; display: none !important; } #erp-fab .erp-fab-dot.show { display: block !important; } #erp-fab .erp-fab-dot.playing { background: #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-fab .erp-fab-dot.paused { background: #ff9800 !important; } @keyframes erp-pulse { 0%,100%{opacity:1;transform:scale(1);} 50%{opacity:0.5;transform:scale(1.3);} } #erp-panel { position: fixed !important; bottom: 90px !important; right: 20px !important; z-index: 2147483647 !important; width: 330px !important; background: linear-gradient(160deg, #1a1a2e 0%, #16213e 55%, #0f3460 100%) !important; border-radius: 20px !important; box-shadow: 0 10px 48px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.09) !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif !important; font-size: 13px !important; color: #e8eaf6 !important; user-select: none !important; overflow: hidden !important; transform-origin: bottom right !important; transition: opacity 0.2s, transform 0.2s !important; opacity: 0 !important; transform: scale(0.85) translateY(10px) !important; pointer-events: none !important; display: block !important; } #erp-panel.open { opacity: 1 !important; transform: scale(1) translateY(0) !important; pointer-events: auto !important; } #erp-panel .erp-hd { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 13px 16px 11px !important; background: rgba(255,255,255,0.05) !important; border-bottom: 1px solid rgba(255,255,255,0.07) !important; cursor: move !important; } #erp-panel .erp-hd-title { font-size: 14px !important; font-weight: 700 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; } #erp-panel .erp-sdot { width: 8px !important; height: 8px !important; border-radius: 50% !important; background: rgba(255,255,255,0.2) !important; flex-shrink: 0 !important; transition: background 0.3s !important; } #erp-panel .erp-sdot.playing { background: #7c4dff !important; box-shadow: 0 0 6px #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-panel .erp-sdot.paused { background: #ff9800 !important; } #erp-panel .erp-hd-close { width: 26px !important; height: 26px !important; border-radius: 7px !important; background: rgba(255,255,255,0.08) !important; border: none !important; color: rgba(255,255,255,0.6) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; transition: all 0.15s !important; } #erp-panel .erp-hd-close:hover { background: rgba(255,100,100,0.25) !important; color: #ff6b6b !important; } #erp-panel .erp-player { padding: 14px 16px 10px !important; } #erp-panel .erp-prog-bar { height: 3px !important; background: rgba(255,255,255,0.1) !important; border-radius: 3px !important; overflow: hidden !important; margin-bottom: 5px !important; } #erp-panel .erp-prog-fill { height: 100% !important; width: 0% !important; background: linear-gradient(90deg,#7c4dff,#e040fb) !important; border-radius: 3px !important; transition: width 0.4s !important; } #erp-panel .erp-prog-info { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.38) !important; margin-bottom: 12px !important; } #erp-panel .erp-controls { display: flex !important; align-items: center !important; justify-content: center !important; gap: 10px !important; } #erp-panel .erp-btn { border: none !important; cursor: pointer !important; outline: none !important; background: transparent !important; color: #e8eaf6 !important; transition: all 0.15s !important; padding: 0 !important; margin: 0 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; font-size: 13px !important; line-height: 1 !important; } #erp-panel .erp-btn:hover { transform: scale(1.12) !important; } #erp-panel .erp-btn:active { transform: scale(0.93) !important; } #erp-panel .erp-ctrl { width: 40px !important; height: 40px !important; border-radius: 50% !important; background: rgba(255,255,255,0.09) !important; font-size: 16px !important; } #erp-panel .erp-ctrl:hover { background: rgba(255,255,255,0.16) !important; } #erp-panel .erp-play-btn { width: 52px !important; height: 52px !important; font-size: 22px !important; background: linear-gradient(135deg,#7c4dff,#e040fb) !important; box-shadow: 0 4px 18px rgba(124,77,255,0.45) !important; border-radius: 50% !important; } #erp-panel .erp-play-btn:hover { box-shadow: 0 6px 24px rgba(124,77,255,0.65) !important; } #erp-panel .erp-stop-btn { color: #ff6b6b !important; } #erp-panel .erp-stop-btn:hover { background: rgba(255,107,107,0.15) !important; } #erp-panel .erp-sep { height: 1px !important; background: rgba(255,255,255,0.07) !important; margin: 10px 0 !important; } #erp-panel .erp-settings { padding: 0 16px 14px !important; } #erp-panel .erp-row { margin-bottom: 11px !important; } #erp-panel .erp-row:last-child { margin-bottom: 0 !important; } #erp-panel .erp-lbl { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.5) !important; margin-bottom: 5px !important; font-weight: 500 !important; } #erp-panel .erp-lbl b { color: rgba(255,255,255,0.85) !important; font-weight: 600 !important; } #erp-panel .erp-tabs { display: flex !important; gap: 4px !important; margin-bottom: 8px !important; background: rgba(255,255,255,0.05) !important; border-radius: 8px !important; padding: 3px !important; } #erp-panel .erp-tab { flex: 1 !important; padding: 5px 0 !important; text-align: center !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; color: rgba(255,255,255,0.45) !important; border: none !important; background: transparent !important; transition: all 0.15s !important; } #erp-panel .erp-tab.active { background: linear-gradient(135deg,#7c4dff,#e040fb) !important; color: #fff !important; box-shadow: 0 2px 8px rgba(124,77,255,0.4) !important; } #erp-panel .erp-select { width: 100% !important; padding: 7px 10px !important; border-radius: 8px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.11) !important; color: #e8eaf6 !important; font-size: 12px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-select:hover, #erp-panel .erp-select:focus { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-select option, #erp-panel .erp-select optgroup { background: #1a1a2e !important; color: #e8eaf6 !important; } #erp-panel .erp-range { width: 100% !important; height: 4px !important; -webkit-appearance: none !important; appearance: none !important; background: rgba(255,255,255,0.14) !important; border-radius: 2px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-range::-webkit-slider-thumb { -webkit-appearance: none !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; box-shadow: 0 0 6px rgba(124,77,255,0.5) !important; } #erp-panel .erp-range::-moz-range-thumb { width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; border: none !important; } #erp-panel .erp-tip { font-size: 11px !important; color: rgba(255,255,255,0.4) !important; text-align: center !important; padding: 4px 0 2px !important; display: none !important; } #erp-panel .erp-tip.show { display: block !important; } #erp-panel .erp-tip.err { color: #ff6b6b !important; } #erp-panel .erp-tip.ok { color: #4caf50 !important; } #erp-panel .erp-tip.err.copyable { cursor: pointer !important; text-decoration: underline dotted #ff6b6b !important; } #erp-panel .erp-tip.err.copyable::after { content: ' 📋' !important; font-style: normal !important; } #erp-panel .erp-tip.copied { color: #4caf50 !important; } #erp-panel .erp-spin { display: inline-block !important; animation: erp-spin 0.8s linear infinite !important; } @keyframes erp-spin { to { transform: rotate(360deg); } } .erp-highlight { background-color: rgba(124,77,255,0.13) !important; border-left: 3px solid #7c4dff !important; padding-left: 8px !important; border-radius: 3px !important; outline: 2px solid rgba(124,77,255,0.22) !important; outline-offset: 3px !important; transition: background-color 0.3s !important; } `); // ===================================================== // 工具函数 // ===================================================== function getVoiceDisplayName(voice) { for (const key of Object.keys(VOICE_ZH_NAME)) { if (voice.name.includes(key)) return VOICE_ZH_NAME[key]; } const name = voice.name .replace(/^Microsoft\s+/i, '微软 ') .replace(/^Google\s+/i, '谷歌 '); const langMap = { 'zh-CN':'普通话', 'zh-HK':'粤语', 'zh-TW':'台湾', 'cmn-CN':'普通话', 'yue-HK':'粤语' }; return `${name}(${langMap[voice.lang] || voice.lang})`; } function genUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } function base64ToArrayBuffer(b64) { const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf.buffer; } // ===================================================== // 提取正文段落 // ===================================================== function extractParagraphs() { const BLOCK = 'p,article,section,blockquote,h1,h2,h3,h4,h5,h6,li,dd,td'; const IGNORE = new Set(['SCRIPT','STYLE','NOSCRIPT','IFRAME','BUTTON','INPUT','TEXTAREA','SELECT','NAV','FOOTER','HEADER']); const isIgnored = el => { let n = el; while (n && n !== document.body) { if (IGNORE.has(n.tagName)) return true; n = n.parentElement; } return false; }; const isVisible = el => { const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'; }; const SELS = ['article','main','.content','.chapter','.book-content','#content','#chapter', '.reader-content','.novel-content','.text-content','.article-content', '.post-content','[role="main"]','.entry-content','.read-content', '.story-body','.article-body','.page-content']; let container = null; for (const s of SELS) { const el = document.querySelector(s); if (el && (el.innerText || '').trim().length > 150) { container = el; break; } } if (!container) container = document.body; const all = [...container.querySelectorAll(BLOCK)]; const filtered = all.filter(el => { if (isIgnored(el) || !isVisible(el)) return false; return (el.innerText || '').trim().replace(/\s+/g, ' ').length > 15; }); return filtered .filter((el, i) => !filtered.some((o, j) => j !== i && o.contains(el) && o !== el)) .map(el => ({ text: (el.innerText || '').trim().replace(/\s+/g, ' '), el })); } // ===================================================== // 加载本地声音列表 // ===================================================== function loadVoices() { return new Promise(resolve => { const v = speechSynthesis.getVoices(); if (v.length) { resolve(v); return; } speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); setTimeout(() => resolve(speechSynthesis.getVoices()), 2500); }); } // ===================================================== // 网络 TTS —— 火山引擎 // 文档:https://www.volcengine.com/docs/6561/79820 // ===================================================== function playAudioBuffer(arrayBuffer, mimeType, onEnd, onError) { try { const blob = new Blob([arrayBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = state.volume; state.audioEl = audio; audio.play().catch(onError); audio.onended = () => { URL.revokeObjectURL(url); onEnd(); }; audio.onerror = () => { URL.revokeObjectURL(url); onError(); }; } catch (e) { onError(e); } } function fetchVolcanoTTS(text, voiceType, onReady, onError) { if (!VOLCANO_CFG.appid || !VOLCANO_CFG.token) { onError('请先在面板中填写火山引擎 AppID 和 Token'); return; } const chunk = text.slice(0, 500); const body = JSON.stringify({ app: { appid: VOLCANO_CFG.appid, token: VOLCANO_CFG.token, cluster: VOLCANO_CFG.cluster, }, user: { uid: 'ebook_tts_user' }, audio: { voice_type: voiceType, encoding: 'mp3', rate: 24000, speed_ratio: state.rate, volume_ratio: state.volume, pitch_ratio: state.pitch, }, request: { reqid: genUUID(), text: chunk, text_type: 'plain', operation: 'query', }, }); GM_xmlhttpRequest({ method: 'POST', url: 'https://openspeech.bytedance.com/api/v1/tts', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer;${VOLCANO_CFG.token}`, }, data: body, timeout: 15000, onload: r => { if (r.status !== 200) { onError('火山引擎 HTTP ' + r.status); return; } let json; try { json = JSON.parse(r.responseText); } catch (e) { onError('返回解析失败'); return; } if (json.code !== 3000) { onError(`火山引擎错误 ${json.code}:${json.message || '未知'}`); return; } if (!json.data) { onError('返回无音频数据'); return; } try { onReady(base64ToArrayBuffer(json.data), 'audio/mpeg'); } catch (e) { onError('音频解码失败'); } }, onerror: () => onError('火山引擎网络错误'), ontimeout: () => onError('火山引擎请求超时'), }); } // ===================================================== // 网络 TTS —— 腾讯云(TC3-HMAC-SHA256 签名) // 文档:https://cloud.tencent.com/document/product/1073/37995 // ===================================================== // 工具:Uint8Array → 十六进制字符串 function buf2hex(buf) { return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } // SHA-256(返回 hex) async function sha256hex(message) { const enc = new TextEncoder(); const buf = await crypto.subtle.digest('SHA-256', enc.encode(message)); return buf2hex(buf); } // HMAC-SHA256(返回 Uint8Array) async function hmacSHA256raw(keyData, message) { const enc = new TextEncoder(); const keyBuf = typeof keyData === 'string' ? enc.encode(keyData) : keyData; const cryptoKey = await crypto.subtle.importKey( 'raw', keyBuf, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message))); } // HMAC-SHA256(返回 hex) async function hmacSHA256hex(keyData, message) { return buf2hex(await hmacSHA256raw(keyData, message)); } async function buildTencentAuth(secretId, secretKey, payload) { const service = 'tts'; const host = 'tts.tencentcloudapi.com'; const algorithm = 'TC3-HMAC-SHA256'; const timestamp = Math.floor(Date.now() / 1000); const date = new Date(timestamp * 1000).toISOString().slice(0, 10); // YYYY-MM-DD // Step 1 – 规范请求串 const httpMethod = 'POST'; const canonicalUri = '/'; const canonicalQuery = ''; const canonicalHeaders= `content-type:application/json\nhost:${host}\nx-tc-action:texttovoice\n`; const signedHeaders = 'content-type;host;x-tc-action'; const hashedPayload = await sha256hex(payload); const canonicalReq = [httpMethod, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, hashedPayload].join('\n'); // Step 2 – 待签名字符串 const credScope = `${date}/${service}/tc3_request`; const hashedCanReq = await sha256hex(canonicalReq); const strToSign = [algorithm, String(timestamp), credScope, hashedCanReq].join('\n'); // Step 3 – 计算签名 const enc = new TextEncoder(); const secretDate = await hmacSHA256raw(enc.encode('TC3' + secretKey), date); const secretSvc = await hmacSHA256raw(secretDate, service); const secretSig = await hmacSHA256raw(secretSvc, 'tc3_request'); const signature = await hmacSHA256hex(secretSig, strToSign); // Step 4 – 拼装 Authorization const authorization = `${algorithm} Credential=${secretId}/${credScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return { authorization, timestamp }; } async function fetchTencentTTS(text, voiceType, onReady, onError) { const { secretId, secretKey } = TENCENT_CFG; if (!secretId || !secretKey) { onError('请先在面板中填写腾讯云 SecretId 和 SecretKey'); return; } // 基础语音合成限制 150 汉字 / 500 字母 const chunk = text.slice(0, 150); // Speed 参数:腾讯云 [-2,6],state.rate 是 [0.5,2.5] → 线性映射到 [-2,6] // rate=1.0 → speed=0;rate=0.5 → speed=-2;rate=2.5 → speed=6 const speed = parseFloat(((state.rate - 1.0) * (8 / 2)).toFixed(1)); const clampedSpeed = Math.min(6, Math.max(-2, speed)); const bodyObj = { Text: chunk, SessionId: genUUID(), VoiceType: voiceType, Codec: 'mp3', // 不传 SampleRate,让腾讯云按各音色默认值处理,避免采样率不匹配报错 Speed: clampedSpeed, Volume: 0, // 腾讯云音量 [-10,10],默认 0 }; const payload = JSON.stringify(bodyObj); let auth; try { auth = await buildTencentAuth(secretId, secretKey, payload); } catch (e) { onError('腾讯云签名失败:' + e.message); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://tts.tencentcloudapi.com/', headers: { 'Content-Type': 'application/json', 'Host': 'tts.tencentcloudapi.com', 'X-TC-Action': 'TextToVoice', 'X-TC-Version': '2019-08-23', 'X-TC-Timestamp': String(auth.timestamp), 'Authorization': auth.authorization, }, data: payload, timeout: 15000, onload: r => { if (r.status !== 200) { onError(`腾讯云 HTTP ${r.status}:${r.responseText}`); return; } let json; try { json = JSON.parse(r.responseText); } catch (e) { onError('腾讯云返回解析失败:' + r.responseText); return; } const resp = json.Response; if (!resp) { onError('腾讯云返回格式异常:' + r.responseText); return; } if (resp.Error) { onError(`腾讯云错误 ${resp.Error.Code}:${resp.Error.Message}`); return; } if (!resp.Audio) { onError('腾讯云返回无音频数据:' + r.responseText); return; } try { onReady(base64ToArrayBuffer(resp.Audio), 'audio/mpeg'); } catch (e) { onError('音频解码失败:' + e.message); } }, onerror: () => onError('腾讯云网络错误'), ontimeout: () => onError('腾讯云请求超时'), }); } function fetchOnlineTTS(text, voiceId, onReady, onError) { const engine = state.voiceMode; // 'volcano' or 'tencent' if (engine === 'tencent') { fetchTencentTTS(text, parseInt(voiceId, 10), onReady, onError); } else { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; fetchVolcanoTTS(text, vo.id, onReady, onError); } } // ===================================================== // 播放控制 // ===================================================== function speak(index) { if (index < 0 || index >= state.paragraphs.length) { stopAll(); return; } abortCurrent(); state.currentIndex = index; state.isPlaying = true; state.isPaused = false; doHighlight(); scrollToEl(state.paragraphs[index].el); updateUI(); const text = state.paragraphs[index].text; const onEnd = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; const onErr = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; if (state.voiceMode !== 'local') { const voiceId = state.selectedVoiceId || (state.voiceMode === 'tencent' ? String(TENCENT_VOICES[0].id) : ONLINE_VOICES[0].id); const engineLabel = state.voiceMode === 'tencent' ? '腾讯云' : '火山引擎'; setTip(`⟳ ${engineLabel}合成中…`); let cancelled = false; state.onlineAbort = () => { cancelled = true; }; fetchOnlineTTS(text, voiceId, (buf, mime) => { if (cancelled || !state.isPlaying || state.currentIndex !== index) return; setTip('✅ 合成成功', 'ok'); playAudioBuffer(buf, mime, onEnd, () => { if (state.isPlaying) onErr(); }); }, (errMsg) => { if (cancelled || !state.isPlaying) return; setTip('⚠ ' + (errMsg || '合成失败') + ',降级本地音色', 'err'); speakLocal(text, onEnd, onErr); } ); } else { speakLocal(text, onEnd, onErr); } } function speakLocal(text, onEnd, onErr) { speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(text); const voices = speechSynthesis.getVoices(); const v = voices.find(v => v.voiceURI === state.selectedVoiceId); if (v) utter.voice = v; utter.rate = state.rate; utter.pitch = state.pitch; utter.volume = state.volume; utter.onend = onEnd; utter.onerror = e => { if (e.error !== 'interrupted' && e.error !== 'canceled') onErr(); }; state.utterance = utter; speechSynthesis.speak(utter); } function abortCurrent() { speechSynthesis.cancel(); if (state.onlineAbort) { state.onlineAbort(); state.onlineAbort = null; } if (state.audioEl) { state.audioEl.pause(); state.audioEl.src = ''; state.audioEl = null; } } function pauseResume() { if (state.isPaused) { if (state.audioEl) state.audioEl.play().catch(() => {}); else speechSynthesis.resume(); state.isPaused = false; state.isPlaying = true; } else if (state.isPlaying) { if (state.audioEl) state.audioEl.pause(); else speechSynthesis.pause(); state.isPaused = true; state.isPlaying = false; } else { state.paragraphs = extractParagraphs(); if (!state.paragraphs.length) { showToast('未找到可朗读的正文,请在文章页面使用'); return; } speak(state.currentIndex || 0); return; } updateUI(); } function stopAll() { abortCurrent(); state.isPlaying = false; state.isPaused = false; clearHighlight(); setTip(''); updateUI(); } function prevPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.max(0, state.currentIndex - 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else state.currentIndex = idx; } function nextPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.min(state.paragraphs.length - 1, state.currentIndex + 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else state.currentIndex = idx; } // ===================================================== // 高亮与滚动 // ===================================================== function doHighlight() { clearHighlight(); const p = state.paragraphs[state.currentIndex]; if (p && p.el) { p.el.classList.add('erp-highlight'); state.highlightEl = p.el; } } function clearHighlight() { document.querySelectorAll('.erp-highlight').forEach(el => el.classList.remove('erp-highlight')); state.highlightEl = null; } function scrollToEl(el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ===================================================== // 创建 UI // ===================================================== function createUI() { if (document.getElementById('erp-fab')) return; // 悬浮球 const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.title = '听书助手(Alt+S 播放)'; fab.innerHTML = `🎧`; document.body.appendChild(fab); // 悬浮面板 const panel = document.createElement('div'); panel.id = 'erp-panel'; panel.innerHTML = `