// ==UserScript== // @name 在线听书助手 🎧 (豆包) // @namespace https://github.com/ebook-tts // @version 1.0.1 // @description 在线阅读时朗读网页内容,支持本地系统音色 + 5路国内网络音色(火山/腾讯/标贝/思必驰/出门问问)、API密钥自定义、语速/音调/音量调节、悬浮球控制面板。快捷键:Alt+S 播放/暂停,Alt+Q 停止,Alt+←/→ 上/下一段,Alt+H 唤回面板。 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect openspeech.bytedance.com // @connect tts.cloud.tencent.com // @connect tts.data-baker.com // @connect api.speechace.cn // @connect api.chumenwenwen.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 1. 全局配置(API密钥 + 网络音色列表) // ===================================================== // API密钥配置(从GM存储读取,安全不泄露) const API_CONFIG = { volc: { ak: GM_getValue('volc_ak', ''), sk: GM_getValue('volc_sk', ''), appId: GM_getValue('volc_appId', '') }, tencent: { secretId: GM_getValue('tencent_secretId', ''), secretKey: GM_getValue('tencent_secretKey', ''), appId: GM_getValue('tencent_appId', '') }, biaobei: { apiKey: GM_getValue('biaobei_apiKey', '') }, sbc: { apiKey: GM_getValue('sbc_apiKey', '') }, cwmw: { apiKey: GM_getValue('cwmw_apiKey', '') } }; // 网络音色列表(替换原有,按平台分组) const ONLINE_VOICES = [ // 火山引擎(字节跳动,首选,额度高) { id: 'volc:BV001_streaming', name: '火山·通用女声(听书适配)', engine: 'volc', voiceType: 'BV001_streaming' }, { id: 'volc:BV002_streaming', name: '火山·通用男声', engine: 'volc', voiceType: 'BV002_streaming' }, { id: 'volc:BV003_streaming', name: '火山·温柔女声', engine: 'volc', voiceType: 'BV003_streaming' }, // 腾讯云 { id: 'tencent:1001', name: '腾讯·云希(通用男声)', engine: 'tencent', voiceType: 1001 }, { id: 'tencent:101001', name: '腾讯·晓晓(通用女声)', engine: 'tencent', voiceType: 101001 }, { id: 'tencent:101002', name: '腾讯·云墨(知性女声)', engine: 'tencent', voiceType: 101002 }, // 标贝 { id: 'biaobei:female_fairy_15', name: '标贝·听书专用女声', engine: 'biaobei', voice: 'female_fairy_15' }, { id: 'biaobei:male_mature_12', name: '标贝·沉稳男声', engine: 'biaobei', voice: 'male_mature_12' }, // 思必驰 { id: 'sbc:sbc_chn_female_common', name: '思必驰·通用女声', engine: 'sbc', voice: 'sbc_chn_female_common' }, { id: 'sbc:sbc_chn_male_common', name: '思必驰·通用男声', engine: 'sbc', voice: 'sbc_chn_male_common' }, // 出门问问 { id: 'cwmw:female_1', name: '出门问问·通用女声', engine: 'cwmw', voice: 'female_1' }, { id: 'cwmw:male_1', name: '出门问问·通用男声', engine: 'cwmw', voice: 'male_1' } ]; // ===================================================== // 2. 本地音色中文名称映射表 // ===================================================== 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 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 3. 播放状态管理 // ===================================================== 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'), onlineAbort: null, }; // ===================================================== // 4. 样式注入(含新增API配置面板样式) // ===================================================== 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: 340px !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; max-height: 320px; overflow-y: auto; } #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; } /* 新增API配置面板样式 */ #erp-panel .erp-config-toggle { width: 100% !important; padding: 6px 10px !important; margin-top: 8px !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; transition: all 0.15s !important; } #erp-panel .erp-config-toggle:hover { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-config-panel { display: none !important; margin-top: 10px !important; padding: 10px !important; border-radius: 8px !important; background: rgba(255,255,255,0.03) !important; border: 1px dashed rgba(255,255,255,0.1) !important; } #erp-panel .erp-config-panel.show { display: block !important; } #erp-panel .erp-config-group { margin-bottom: 10px !important; padding-bottom: 10px !important; border-bottom: 1px dashed rgba(255,255,255,0.08) !important; } #erp-panel .erp-config-group:last-child { margin-bottom: 0 !important; padding-bottom: 0 !important; border-bottom: none !important; } #erp-panel .erp-config-title { font-size: 12px !important; font-weight: 600 !important; color: rgba(255,255,255,0.8) !important; margin-bottom: 6px !important; } #erp-panel .erp-config-input { width: 100% !important; padding: 6px 8px !important; margin-bottom: 4px !important; border-radius: 6px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.11) !important; color: #e8eaf6 !important; font-size: 11px !important; outline: none !important; box-sizing: border-box !important; } #erp-panel .erp-config-input:hover, #erp-panel .erp-config-input:focus { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-save-config { width: 100% !important; padding: 8px 10px !important; margin-top: 6px !important; border-radius: 8px !important; background: linear-gradient(135deg,#4caf50,#66bb6a) !important; border: none !important; color: #fff !important; font-size: 12px !important; font-weight: 600 !important; cursor: pointer !important; outline: none !important; transition: all 0.15s !important; } #erp-panel .erp-save-config:hover { transform: scale(1.02) !important; box-shadow: 0 2px 8px rgba(76,175,80,0.4) !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-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; } `); // ===================================================== // 5. 工具函数 // ===================================================== 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})`; } // 工具函数:HMAC-SHA256签名(火山/腾讯云通用) async function hmacSHA256(message, secret) { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const messageData = encoder.encode(message); const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const signature = await crypto.subtle.sign('HMAC', key, messageData); return btoa(String.fromCharCode(...new Uint8Array(signature))); } // 工具函数:URL安全的Base64编码 function base64UrlEncode(str) { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } // ===================================================== // 6. 提取正文段落 // ===================================================== 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 })); } // ===================================================== // 7. 加载本地声音列表 // ===================================================== 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); }); } // ===================================================== // 8. 网络TTS核心函数(替换原有,新增5个平台) // ===================================================== 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); } } // 8.1 火山引擎TTS async function fetchVolcTTS(text, voiceType, onReady, onError) { if (!API_CONFIG.volc.ak || !API_CONFIG.volc.sk || !API_CONFIG.volc.appId) { onError('请先配置火山引擎API密钥'); return; } const timestamp = Math.floor(Date.now() / 1000); const nonce = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); const stringToSign = `POST\n/api/v1/tts\n${timestamp}\n${nonce}\n${API_CONFIG.volc.appId}`; const signature = await hmacSHA256(stringToSign, API_CONFIG.volc.sk); GM_xmlhttpRequest({ method: 'POST', url: 'https://openspeech.bytedance.com/api/v1/tts', headers: { 'Content-Type': 'application/json', 'X-AppId': API_CONFIG.volc.appId, 'X-Timestamp': timestamp.toString(), 'X-Nonce': nonce, 'Authorization': signature }, data: JSON.stringify({ text: text.slice(0, 500), // 火山单次最大500字 voice_type: voiceType, codec: 'mp3', speed_ratio: state.rate, volume_ratio: state.volume }), responseType: 'arraybuffer', timeout: 15000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('火山引擎请求失败:' + r.status), onerror: () => onError('火山引擎网络错误'), ontimeout:() => onError('火山引擎超时'), }); } // 8.2 腾讯云TTS async function fetchTencentTTS(text, voiceType, onReady, onError) { if (!API_CONFIG.tencent.secretId || !API_CONFIG.tencent.secretKey || !API_CONFIG.tencent.appId) { onError('请先配置腾讯云API密钥'); return; } const timestamp = Math.floor(Date.now() / 1000); const nonce = Math.floor(Math.random() * 1000000); const spd = Math.round(Math.min(Math.max(state.rate, 0.5), 2.5) * 50); // 腾讯语速0-200 const chunk = text.slice(0, 300); // 腾讯单次最大300字 // 简化版腾讯云签名(仅用于演示,生产环境建议用官方SDK) const params = { Action: 'TextToVoice', AppId: API_CONFIG.tencent.appId, SecretId: API_CONFIG.tencent.secretId, Timestamp: timestamp, Nonce: nonce, Text: encodeURIComponent(chunk), SessionId: Math.random().toString(36).substring(2, 15), ModelType: 1, Speed: spd, Volume: Math.round(state.volume * 10), VoiceType: voiceType, Codec: 'mp3' }; // 生成签名 const sortedKeys = Object.keys(params).sort(); let signStr = 'GETtts.cloud.tencent.com/text2voice?'; sortedKeys.forEach(key => signStr += `${key}=${params[key]}&`); signStr = signStr.slice(0, -1); const signature = await hmacSHA256(signStr, API_CONFIG.tencent.secretKey); const finalUrl = `https://tts.cloud.tencent.com/text2voice?${new URLSearchParams(params).toString()}&Signature=${encodeURIComponent(signature)}`; GM_xmlhttpRequest({ method: 'GET', url: finalUrl, responseType: 'arraybuffer', timeout: 15000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('腾讯云请求失败:' + r.status), onerror: () => onError('腾讯云网络错误'), ontimeout:() => onError('腾讯云超时'), }); } // 8.3 标贝TTS function fetchBiaobeiTTS(text, voice, onReady, onError) { if (!API_CONFIG.biaobei.apiKey) { onError('请先配置标贝API密钥'); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://tts.data-baker.com/tts/v1/tts', headers: { 'Content-Type': 'application/json', 'Authorization': API_CONFIG.biaobei.apiKey }, data: JSON.stringify({ text: text.slice(0, 300), // 标贝单次最大300字 voice: voice, format: 'mp3', speed: Math.round(state.rate * 5), // 标贝语速0-9 volume: 5 }), responseType: 'arraybuffer', timeout: 15000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('标贝请求失败:' + r.status), onerror: () => onError('标贝网络错误'), ontimeout:() => onError('标贝超时'), }); } // 8.4 思必驰TTS function fetchSbcTTS(text, voice, onReady, onError) { if (!API_CONFIG.sbc.apiKey) { onError('请先配置思必驰API密钥'); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://api.speechace.cn/tts/v1/synthesize', headers: { 'Content-Type': 'application/json', 'apiKey': API_CONFIG.sbc.apiKey }, data: JSON.stringify({ text: text.slice(0, 300), // 思必驰单次最大300字 voice: voice, speed: state.rate, volume: state.volume }), responseType: 'arraybuffer', timeout: 15000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('思必驰请求失败:' + r.status), onerror: () => onError('思必驰网络错误'), ontimeout:() => onError('思必驰超时'), }); } // 8.5 出门问问TTS function fetchCwmwTTS(text, voice, onReady, onError) { if (!API_CONFIG.cwmw.apiKey) { onError('请先配置出门问问API密钥'); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://api.chumenwenwen.com/tts/v1/play', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_CONFIG.cwmw.apiKey}` }, data: JSON.stringify({ text: text.slice(0, 300), // 出门问问单次最大300字 voice: voice, speed: state.rate, volume: state.volume }), responseType: 'arraybuffer', timeout: 15000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('出门问问请求失败:' + r.status), onerror: () => onError('出门问问网络错误'), ontimeout:() => onError('出门问问超时'), }); } // 8.6 统一网络TTS入口 function fetchOnlineTTS(text, voiceId, onReady, onError) { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; const fallback = (err) => { showTip(`${err},已切换到本地音色`, 'err'); state.voiceMode = 'local'; GM_setValue('voiceMode', 'local'); renderVoiceSelect(); playCurrentParagraph(); }; if (vo.engine === 'volc') { fetchVolcTTS(text, vo.voiceType, onReady, fallback); } else if (vo.engine === 'tencent') { fetchTencentTTS(text, vo.voiceType, onReady, fallback); } else if (vo.engine === 'biaobei') { fetchBiaobeiTTS(text, vo.voice, onReady, fallback); } else if (vo.engine === 'sbc') { fetchSbcTTS(text, vo.voice, onReady, fallback); } else if (vo.engine === 'cwmw') { fetchCwmwTTS(text, vo.voice, onReady, fallback); } else { onError('未知网络TTS引擎'); } } // ===================================================== // 9. 播放控制核心逻辑 // ===================================================== function clearHighlight() { if (state.highlightEl) { state.highlightEl.classList.remove('erp-highlight'); state.highlightEl = null; } } function setHighlight(el) { clearHighlight(); if (el) { el.classList.add('erp-highlight'); state.highlightEl = el; el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } function stopPlayback() { state.isPlaying = false; state.isPaused = false; if (state.utterance) { speechSynthesis.cancel(state.utterance); state.utterance = null; } if (state.audioEl) { state.audioEl.pause(); state.audioEl = null; } if (state.onlineAbort) { state.onlineAbort(); state.onlineAbort = null; } clearHighlight(); updateUI(); } function playNextParagraph() { if (state.currentIndex >= state.paragraphs.length - 1) { stopPlayback(); showTip('播放完成 🎉', 'ok'); return; } state.currentIndex++; playCurrentParagraph(); } function playPrevParagraph() { if (state.currentIndex <= 0) { showTip('已是第一段', 'err'); return; } stopPlayback(); state.currentIndex--; playCurrentParagraph(); } function playCurrentParagraph() { if (state.paragraphs.length === 0) { showTip('未检测到可朗读内容', 'err'); stopPlayback(); return; } if (state.currentIndex < 0) state.currentIndex = 0; if (state.currentIndex >= state.paragraphs.length) state.currentIndex = state.paragraphs.length - 1; const current = state.paragraphs[state.currentIndex]; setHighlight(current.el); state.isPlaying = true; state.isPaused = false; updateUI(); // 本地 TTS if (state.voiceMode === 'local') { stopPlayback(); // 先停止之前的播放 const utterance = new SpeechSynthesisUtterance(current.text); utterance.rate = state.rate; utterance.pitch = state.pitch; utterance.volume = state.volume; // 匹配选中的本地音色 if (state.selectedVoiceId) { const voice = state.voices.find(v => v.name === state.selectedVoiceId || v.voiceURI === state.selectedVoiceId); if (voice) utterance.voice = voice; } utterance.onend = () => { state.utterance = null; playNextParagraph(); }; utterance.onerror = (e) => { showTip('本地TTS播放失败: ' + e.error, 'err'); state.utterance = null; stopPlayback(); }; state.utterance = utterance; speechSynthesis.speak(utterance); } // 网络 TTS else { stopPlayback(); // 先停止之前的播放 const voice = ONLINE_VOICES.find(v => v.id === state.selectedVoiceId); if (!voice) { showTip('未选择有效网络音色', 'err'); stopPlayback(); return; } const onEnd = () => { state.onlineAbort = null; playNextParagraph(); }; const onError = (msg) => { showTip(`网络TTS错误: ${msg}`, 'err'); state.onlineAbort = null; stopPlayback(); }; // 发起网络TTS请求 setTip(' 正在合成音频…'); fetchOnlineTTS(current.text, voice.id, (buf, mime) => { if (!state.isPlaying || state.currentIndex !== state.paragraphs.indexOf(current)) return; setTip('✅ 音频合成成功', 'ok'); playAudioBuffer(buf, mime, onEnd, onError); }, onError ); } } function togglePlayPause() { if (state.paragraphs.length === 0) { state.paragraphs = extractParagraphs(); if (state.paragraphs.length === 0) { showTip('未检测到可朗读内容', 'err'); return; } } if (state.isPlaying) { if (state.isPaused) { // 恢复播放 state.isPaused = false; if (state.voiceMode === 'local' && state.utterance) { speechSynthesis.resume(); } else if (state.audioEl) { state.audioEl.play().catch(e => { showTip('恢复播放失败: ' + e.message, 'err'); stopPlayback(); }); } updateUI(); } else { // 暂停播放 state.isPaused = true; if (state.voiceMode === 'local') { speechSynthesis.pause(); } else if (state.audioEl) { state.audioEl.pause(); } updateUI(); } } else { // 开始播放 playCurrentParagraph(); } } // ===================================================== // 10. UI 渲染与更新 // ===================================================== function showTip(text, type = '') { const tipEl = document.querySelector('#erp-panel .erp-tip'); if (tipEl) { tipEl.innerHTML = text; tipEl.className = `erp-tip show ${type}`; clearTimeout(tipEl.timer); tipEl.timer = setTimeout(() => { tipEl.classList.remove('show'); }, 3000); } } function updateProgress() { const progFill = document.querySelector('#erp-panel .erp-prog-fill'); const progInfo = document.querySelector('#erp-panel .erp-prog-info'); if (progFill && progInfo && state.paragraphs.length > 0) { const percent = ((state.currentIndex + 1) / state.paragraphs.length) * 100; progFill.style.width = `${percent}%`; progInfo.textContent = `${state.currentIndex + 1} / ${state.paragraphs.length}`; } } function updateUI() { // 更新悬浮球状态 const fabDot = document.querySelector('#erp-fab .erp-fab-dot'); if (fabDot) { fabDot.classList.add('show'); if (state.isPlaying) { fabDot.classList.toggle('playing', !state.isPaused); fabDot.classList.toggle('paused', state.isPaused); } else { fabDot.classList.remove('playing', 'paused'); } } // 更新面板状态 const panelDot = document.querySelector('#erp-panel .erp-sdot'); const playBtn = document.querySelector('#erp-panel .erp-play-btn'); if (panelDot && playBtn) { if (state.isPlaying) { panelDot.classList.toggle('playing', !state.isPaused); panelDot.classList.toggle('paused', state.isPaused); playBtn.innerHTML = state.isPaused ? '▶' : '❚❚'; } else { panelDot.classList.remove('playing', 'paused'); playBtn.innerHTML = '▶'; } } // 更新进度条 updateProgress(); } function renderVoiceSelect() { const voiceSelect = document.querySelector('#erp-voice-select'); if (!voiceSelect) return; voiceSelect.innerHTML = ''; // 按模式渲染音色列表 if (state.voiceMode === 'local') { // 本地音色 const localVoices = state.voices.filter(v => v.lang && v.lang.includes('zh')); if (localVoices.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '未检测到本地中文音色'; voiceSelect.appendChild(opt); return; } localVoices.forEach(voice => { const opt = document.createElement('option'); opt.value = voice.name || voice.voiceURI; opt.textContent = getVoiceDisplayName(voice); opt.selected = (voice.name === state.selectedVoiceId || voice.voiceURI === state.selectedVoiceId); voiceSelect.appendChild(opt); }); } else { // 网络音色(按平台分组) const groups = { '🔮 火山引擎': ONLINE_VOICES.filter(v => v.engine === 'volc'), '🐧 腾讯云': ONLINE_VOICES.filter(v => v.engine === 'tencent'), '🎤 标贝': ONLINE_VOICES.filter(v => v.engine === 'biaobei'), '🎧 思必驰': ONLINE_VOICES.filter(v => v.engine === 'sbc'), '🎵 出门问问': ONLINE_VOICES.filter(v => v.engine === 'cwmw') }; Object.entries(groups).forEach(([label, list]) => { if (!list.length) return; const og = document.createElement('optgroup'); og.label = label; list.forEach(v => { const opt = document.createElement('option'); opt.value = v.id; opt.textContent = v.name; opt.selected = (v.id === state.selectedVoiceId); og.appendChild(opt); }); voiceSelect.appendChild(og); }); // 默认选第一个网络音色 if (!state.selectedVoiceId || !ONLINE_VOICES.find(v => v.id === state.selectedVoiceId)) { state.selectedVoiceId = ONLINE_VOICES[0].id; voiceSelect.value = state.selectedVoiceId; } } } function createPanel() { // 避免重复创建 if (document.querySelector('#erp-panel')) return; const panel = document.createElement('div'); panel.id = 'erp-panel'; panel.innerHTML = `
在线听书助手 (豆包)
0 / 0
朗读音色 -
语速 ${state.rate.toFixed(1)}x
音调 ${state.pitch.toFixed(1)}x
音量 ${state.volume.toFixed(1)}x
🔮 火山引擎
🐧 腾讯云
🎤 标贝
🎧 思必驰
🎵 出门问问
`; document.body.appendChild(panel); // 面板拖拽 const hd = panel.querySelector('.erp-hd'); let isDragging = false; let offsetX, offsetY; hd.addEventListener('mousedown', (e) => { isDragging = true; const rect = panel.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; panel.style.transition = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; panel.style.left = `${e.clientX - offsetX}px`; panel.style.top = `${e.clientY - offsetY}px`; panel.style.bottom = 'auto'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { isDragging = false; panel.style.transition = 'opacity 0.2s, transform 0.2s'; }); // 绑定面板事件 panel.querySelector('.erp-hd-close').addEventListener('click', () => { panel.classList.remove('open'); state.panelOpen = false; }); // 音色模式切换 panel.querySelectorAll('.erp-tab').forEach(tab => { tab.addEventListener('click', () => { panel.querySelectorAll('.erp-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); state.voiceMode = tab.dataset.mode; GM_setValue('voiceMode', state.voiceMode); // 网络音色隐藏音调调节 document.getElementById('erp-pitch-row').style.display = state.voiceMode === 'online' ? 'none' : ''; renderVoiceSelect(); stopPlayback(); }); }); // 音色选择 panel.querySelector('#erp-voice-select').addEventListener('change', (e) => { state.selectedVoiceId = e.target.value; GM_setValue('selectedVoiceId', state.selectedVoiceId); const voiceName = e.target.options[e.target.selectedIndex].text; panel.querySelector('#erp-voice-name').textContent = voiceName; stopPlayback(); }); // 语速调节 const rateInput = panel.querySelector('#erp-rate'); const rateVal = panel.querySelector('#erp-rate-val'); rateInput.addEventListener('input', (e) => { state.rate = parseFloat(e.target.value); rateVal.textContent = `${state.rate.toFixed(1)}x`; GM_setValue('rate', state.rate); if (state.utterance) state.utterance.rate = state.rate; }); // 音调调节 const pitchInput = panel.querySelector('#erp-pitch'); const pitchVal = panel.querySelector('#erp-pitch-val'); pitchInput.addEventListener('input', (e) => { state.pitch = parseFloat(e.target.value); pitchVal.textContent = `${state.pitch.toFixed(1)}x`; GM_setValue('pitch', state.pitch); if (state.utterance) state.utterance.pitch = state.pitch; }); // 音量调节 const volumeInput = panel.querySelector('#erp-volume'); const volumeVal = panel.querySelector('#erp-volume-val'); volumeInput.addEventListener('input', (e) => { state.volume = parseFloat(e.target.value); volumeVal.textContent = `${state.volume.toFixed(1)}x`; GM_setValue('volume', state.volume); if (state.utterance) state.utterance.volume = state.volume; if (state.audioEl) state.audioEl.volume = state.volume; }); // 播放控制按钮 panel.querySelector('.erp-play-btn').addEventListener('click', togglePlayPause); panel.querySelector('.erp-stop-btn').addEventListener('click', stopPlayback); panel.querySelector('.erp-prev-btn').addEventListener('click', playPrevParagraph); panel.querySelector('.erp-next-btn').addEventListener('click', playNextParagraph); // API配置面板展开/收起 const configToggle = panel.querySelector('#erp-config-toggle'); const configPanel = panel.querySelector('#erp-config-panel'); configToggle.addEventListener('click', () => { configPanel.classList.toggle('show'); }); // 保存API配置 panel.querySelector('#erp-save-config').addEventListener('click', () => { // 保存火山引擎 API_CONFIG.volc.ak = document.getElementById('volc-ak').value; API_CONFIG.volc.sk = document.getElementById('volc-sk').value; API_CONFIG.volc.appId = document.getElementById('volc-appId').value; GM_setValue('volc_ak', API_CONFIG.volc.ak); GM_setValue('volc_sk', API_CONFIG.volc.sk); GM_setValue('volc_appId', API_CONFIG.volc.appId); // 保存腾讯云 API_CONFIG.tencent.secretId = document.getElementById('tencent-secretId').value; API_CONFIG.tencent.secretKey = document.getElementById('tencent-secretKey').value; API_CONFIG.tencent.appId = document.getElementById('tencent-appId').value; GM_setValue('tencent_secretId', API_CONFIG.tencent.secretId); GM_setValue('tencent_secretKey', API_CONFIG.tencent.secretKey); GM_setValue('tencent_appId', API_CONFIG.tencent.appId); // 保存标贝 API_CONFIG.biaobei.apiKey = document.getElementById('biaobei-apiKey').value; GM_setValue('biaobei_apiKey', API_CONFIG.biaobei.apiKey); // 保存思必驰 API_CONFIG.sbc.apiKey = document.getElementById('sbc-apiKey').value; GM_setValue('sbc_apiKey', API_CONFIG.sbc.apiKey); // 保存出门问问 API_CONFIG.cwmw.apiKey = document.getElementById('cwmw-apiKey').value; GM_setValue('cwmw_apiKey', API_CONFIG.cwmw.apiKey); showTip('✅ API配置保存成功', 'ok'); }); } function createFAB() { // 避免重复创建 if (document.querySelector('#erp-fab')) return; const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.innerHTML = '🎧'; document.body.appendChild(fab); // 悬浮球点击事件 fab.addEventListener('click', () => { const panel = document.querySelector('#erp-panel'); if (panel) { state.panelOpen = !state.panelOpen; panel.classList.toggle('open', state.panelOpen); if (state.panelOpen) { updateProgress(); renderVoiceSelect(); // 网络音色隐藏音调调节 document.getElementById('erp-pitch-row').style.display = state.voiceMode === 'online' ? 'none' : ''; // 填充音色名称 const voiceSelect = document.querySelector('#erp-voice-select'); if (voiceSelect && voiceSelect.options.length > 0) { const voiceName = voiceSelect.options[voiceSelect.selectedIndex].text; document.querySelector('#erp-voice-name').textContent = voiceName; } } } }); } // ===================================================== // 11. 快捷键绑定 // ===================================================== function bindShortcuts() { document.addEventListener('keydown', (e) => { // Alt+S: 播放/暂停 if (e.altKey && e.key === 's') { e.preventDefault(); togglePlayPause(); } // Alt+Q: 停止 if (e.altKey && e.key === 'q') { e.preventDefault(); stopPlayback(); } // Alt+左箭头: 上一段 if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); playPrevParagraph(); } // Alt+右箭头: 下一段 if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); playNextParagraph(); } // Alt+H: 唤回面板 if (e.altKey && e.key === 'h') { e.preventDefault(); const panel = document.querySelector('#erp-panel'); if (panel) { state.panelOpen = true; panel.classList.add('open'); updateProgress(); } } }); } // ===================================================== // 12. 初始化 // ===================================================== async function init() { // 创建UI createFAB(); createPanel(); // 加载本地音色 state.voices = await loadVoices(); // 绑定快捷键 bindShortcuts(); // 初始更新UI updateUI(); // 页面切换/加载时重新提取段落 const observer = new MutationObserver(() => { if (!state.isPlaying) { state.paragraphs = extractParagraphs(); updateProgress(); } }); observer.observe(document.body, { childList: true, subtree: true }); } // 启动脚本 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();