// ==UserScript== // @name 全能听书语音合成插件 // @namespace https://tampermonkey.net/ // @version 1.0 // @description 集成多款国内免费语音合成的油猴听书插件,支持自定义API密钥、悬浮控制框 // @author You // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @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-end // ==/UserScript== (function() { 'use strict'; // ====================== 1. 全局配置与工具函数 ====================== // 存储各平台配置(默认值从GM存储读取) const 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', '') }, // 播放配置 play: { platform: GM_getValue('default_platform', 'volc'), speed: GM_getValue('default_speed', 1.0), currentAudio: null, isPlaying: false } }; // 工具函数:生成随机字符串(用于签名) function randomStr(len = 16) { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let str = ''; for (let i = 0; i < len; i++) str += chars[Math.floor(Math.random() * chars.length)]; return str; } // 工具函数:HMAC-SHA256签名(火山/腾讯云通用) function hmacSHA256(message, secret) { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const messageData = encoder.encode(message); return crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) .then(key => crypto.subtle.sign('HMAC', key, messageData)) .then(signature => btoa(String.fromCharCode(...new Uint8Array(signature)))); } // 工具函数:URL安全的Base64编码 function base64UrlEncode(str) { return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } // 工具函数:提示框 function showToast(text, type = 'info') { const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 8px 16px; background: ${type === 'error' ? '#ff4444' : '#33b5e5'}; color: white; border-radius: 4px; z-index: 999999; font-size: 14px; `; toast.textContent = text; document.body.appendChild(toast); setTimeout(() => toast.remove(), 3000); } // ====================== 2. 各平台TTS核心函数 ====================== /** * 火山引擎TTS * @param {string} text 朗读文本 * @param {number} speed 语速(1.0为正常) * @returns {Promise} 音频Blob */ async function volcTTS(text, speed = 1.0) { if (!CONFIG.volc.ak || !CONFIG.volc.sk || !CONFIG.volc.appId) { throw new Error('请先配置火山引擎AK/SK/AppId'); } const timestamp = Math.floor(Date.now() / 1000); const nonce = randomStr(); const stringToSign = `POST\n/api/v1/tts\n${timestamp}\n${nonce}\n${CONFIG.volc.appId}`; const signature = await hmacSHA256(stringToSign, CONFIG.volc.sk); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://openspeech.bytedance.com/api/v1/tts', headers: { 'Content-Type': 'application/json', 'X-AppId': CONFIG.volc.appId, 'X-Timestamp': timestamp.toString(), 'X-Nonce': nonce, 'Authorization': signature }, data: JSON.stringify({ text, voice_type: 'BV001_streaming', // 通用女声 codec: 'mp3', speed_ratio: speed, volume_ratio: 1.0 }), responseType: 'blob', onload: (res) => { if (res.status === 200) resolve(res.response); else reject(new Error(`火山引擎请求失败:${res.statusText}`)); }, onerror: (err) => reject(new Error(`火山引擎网络错误:${err.message}`)) }); }); } /** * 腾讯云TTS * @param {string} text 朗读文本 * @param {number} speed 语速(1.0为正常) * @returns {Promise} 音频Blob */ async function tencentTTS(text, speed = 1.0) { if (!CONFIG.tencent.secretId || !CONFIG.tencent.secretKey || !CONFIG.tencent.appId) { throw new Error('请先配置腾讯云SecretId/SecretKey/AppId'); } const timestamp = Math.floor(Date.now() / 1000); const nonce = Math.floor(Math.random() * 1000000); const params = { Action: 'TextToSpeech', AppId: CONFIG.tencent.appId, SecretId: CONFIG.tencent.secretId, Timestamp: timestamp, Nonce: nonce, Text: encodeURIComponent(text), SessionId: randomStr(), ModelType: 1, Speed: speed * 100, // 腾讯云语速范围0-200 Volume: 100, VoiceType: 1001, // 通用女声 Codec: 'mp3' }; // 生成签名 const sortedKeys = Object.keys(params).sort(); let signStr = 'GETtts.cloud.tencent.com/stream_v2/?'; sortedKeys.forEach(key => signStr += `${key}=${params[key]}&`); signStr = signStr.slice(0, -1); const signature = await hmacSHA256(signStr, CONFIG.tencent.secretKey); const finalUrl = `https://tts.cloud.tencent.com/stream_v2/?${new URLSearchParams(params).toString()}&Signature=${encodeURIComponent(signature)}`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: finalUrl, responseType: 'blob', onload: (res) => { if (res.status === 200) resolve(res.response); else reject(new Error(`腾讯云请求失败:${res.statusText}`)); }, onerror: (err) => reject(new Error(`腾讯云网络错误:${err.message}`)) }); }); } /** * 标贝TTS * @param {string} text 朗读文本 * @param {number} speed 语速(1.0为正常,对应标贝5) * @returns {Promise} 音频Blob */ async function biaobeiTTS(text, speed = 1.0) { if (!CONFIG.biaobei.apiKey) { throw new Error('请先配置标贝API Key'); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://tts.data-baker.com/tts/v1/tts', headers: { 'Content-Type': 'application/json', 'Authorization': CONFIG.biaobei.apiKey }, data: JSON.stringify({ text, voice: 'female_fairy_15', // 听书专用女声 format: 'mp3', speed: Math.floor(speed * 5), // 标贝语速0-9 volume: 5 }), responseType: 'blob', onload: (res) => { if (res.status === 200) resolve(res.response); else reject(new Error(`标贝请求失败:${res.statusText}`)); }, onerror: (err) => reject(new Error(`标贝网络错误:${err.message}`)) }); }); } /** * 思必驰TTS * @param {string} text 朗读文本 * @param {number} speed 语速(1.0为正常) * @returns {Promise} 音频Blob */ async function sbcTTS(text, speed = 1.0) { if (!CONFIG.sbc.apiKey) { throw new Error('请先配置思必驰API Key'); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.speechace.cn/tts/v1/synthesize', headers: { 'Content-Type': 'application/json', 'apiKey': CONFIG.sbc.apiKey }, data: JSON.stringify({ text, voice: 'sbc_chn_female_common', // 通用女声 speed: speed, volume: 1.0 }), responseType: 'blob', onload: (res) => { if (res.status === 200) resolve(res.response); else reject(new Error(`思必驰请求失败:${res.statusText}`)); }, onerror: (err) => reject(new Error(`思必驰网络错误:${err.message}`)) }); }); } /** * 出门问问TTS * @param {string} text 朗读文本 * @param {number} speed 语速(1.0为正常) * @returns {Promise} 音频Blob */ async function cwmwTTS(text, speed = 1.0) { if (!CONFIG.cwmw.apiKey) { throw new Error('请先配置出门问问API Key'); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.chumenwenwen.com/tts/v1/play', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CONFIG.cwmw.apiKey}` }, data: JSON.stringify({ text, voice: 'female_1', // 通用女声 speed: speed, volume: 1.0 }), responseType: 'blob', onload: (res) => { if (res.status === 200) resolve(res.response); else reject(new Error(`出门问问请求失败:${res.statusText}`)); }, onerror: (err) => reject(new Error(`出门问问网络错误:${err.message}`)) }); }); } // ====================== 3. 播放控制函数 ====================== /** * 播放指定文本 * @param {string} text 朗读文本 * @param {string} platform 平台(volc/tencent/biaobei/sbc/cwmw) * @param {number} speed 语速 */ async function playText(text, platform, speed) { // 停止当前播放 if (CONFIG.play.currentAudio) { CONFIG.play.currentAudio.pause(); CONFIG.play.currentAudio = null; } if (!text.trim()) { showToast('请输入或选择要朗读的文本', 'error'); return; } try { showToast(`正在合成${{ volc: '火山引擎', tencent: '腾讯云', biaobei: '标贝', sbc: '思必驰', cwmw: '出门问问' }[platform]}语音...`); // 根据平台调用对应TTS let blob; switch (platform) { case 'volc': blob = await volcTTS(text, speed); break; case 'tencent': blob = await tencentTTS(text, speed); break; case 'biaobei': blob = await biaobeiTTS(text, speed); break; case 'sbc': blob = await sbcTTS(text, speed); break; case 'cwmw': blob = await cwmwTTS(text, speed); break; default: throw new Error('不支持的语音合成平台'); } // 创建音频并播放 const audioUrl = URL.createObjectURL(blob); const audio = new Audio(audioUrl); audio.play(); CONFIG.play.currentAudio = audio; CONFIG.play.isPlaying = true; // 播放完成清理 audio.onended = () => { URL.revokeObjectURL(audioUrl); CONFIG.play.isPlaying = false; showToast('朗读完成'); }; // 播放错误处理 audio.onerror = (err) => { URL.revokeObjectURL(audioUrl); CONFIG.play.isPlaying = false; showToast(`音频播放失败:${err.message}`, 'error'); }; showToast('开始朗读'); } catch (err) { showToast(err.message, 'error'); CONFIG.play.isPlaying = false; } } // 暂停/继续播放 function togglePlay() { if (!CONFIG.play.currentAudio) { showToast('暂无播放内容', 'error'); return; } if (CONFIG.play.isPlaying) { CONFIG.play.currentAudio.pause(); CONFIG.play.isPlaying = false; showToast('暂停朗读'); } else { CONFIG.play.currentAudio.play(); CONFIG.play.isPlaying = true; showToast('继续朗读'); } } // 停止播放 function stopPlay() { if (CONFIG.play.currentAudio) { CONFIG.play.currentAudio.pause(); URL.revokeObjectURL(CONFIG.play.currentAudio.src); CONFIG.play.currentAudio = null; CONFIG.play.isPlaying = false; showToast('停止朗读'); } } // ====================== 4. 悬浮UI渲染 ====================== function renderUI() { // 主悬浮容器 const container = document.createElement('div'); container.id = 'tts-plugin-container'; container.style.cssText = ` position: fixed; right: 20px; bottom: 20px; z-index: 999999; width: 320px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); overflow: hidden; `; // 标题栏 const header = document.createElement('div'); header.style.cssText = ` padding: 8px 16px; background: #33b5e5; color: white; font-size: 14px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; `; header.innerHTML = ` 全能听书插件 `; // 内容区域 const content = document.createElement('div'); content.id = 'tts-plugin-content'; content.style.cssText = ` padding: 16px; max-height: 400px; overflow-y: auto; `; // 内容HTML content.innerHTML = `
${CONFIG.play.speed}x
`; // 组装UI container.appendChild(header); container.appendChild(content); document.body.appendChild(container); // ====================== 5. 事件绑定 ====================== // 折叠/展开面板 const collapseBtn = document.getElementById('tts-collapse-btn'); const pluginContent = document.getElementById('tts-plugin-content'); let isCollapsed = false; collapseBtn.addEventListener('click', () => { isCollapsed = !isCollapsed; pluginContent.style.display = isCollapsed ? 'none' : 'block'; collapseBtn.textContent = isCollapsed ? '▶' : '▼'; container.style.width = isCollapsed ? '40px' : '320px'; header.style.justifyContent = isCollapsed ? 'center' : 'space-between'; header.querySelector('span').style.display = isCollapsed ? 'none' : 'inline'; }); // 读取选中文本 document.getElementById('tts-get-selected').addEventListener('click', () => { const selectedText = window.getSelection().toString().trim(); if (selectedText) { document.getElementById('tts-text').value = selectedText; showToast('已读取选中文本'); } else { showToast('未选中任何文本', 'error'); } }); // 语速滑块 const speedSlider = document.getElementById('tts-speed'); const speedText = document.getElementById('tts-speed-text'); speedSlider.addEventListener('input', () => { const speed = parseFloat(speedSlider.value); speedText.textContent = `${speed.toFixed(1)}x`; CONFIG.play.speed = speed; GM_setValue('default_speed', speed); }); // 平台选择 const platformSelect = document.getElementById('tts-platform'); platformSelect.value = CONFIG.play.platform; platformSelect.addEventListener('change', () => { CONFIG.play.platform = platformSelect.value; GM_setValue('default_platform', platformSelect.value); }); // 播放 document.getElementById('tts-play').addEventListener('click', () => { const text = document.getElementById('tts-text').value; const platform = platformSelect.value; const speed = parseFloat(speedSlider.value); playText(text, platform, speed); }); // 暂停/继续 document.getElementById('tts-pause').addEventListener('click', togglePlay); // 停止 document.getElementById('tts-stop').addEventListener('click', stopPlay); // 展开/收起配置面板 document.getElementById('tts-config-toggle').addEventListener('click', () => { const configPanel = document.getElementById('tts-config-panel'); configPanel.style.display = configPanel.style.display === 'none' ? 'block' : 'none'; }); // 保存配置 document.getElementById('tts-save-config').addEventListener('click', () => { // 保存火山引擎 GM_setValue('volc_ak', document.getElementById('volc-ak').value); GM_setValue('volc_sk', document.getElementById('volc-sk').value); GM_setValue('volc_appId', document.getElementById('volc-appId').value); // 保存腾讯云 GM_setValue('tencent_secretId', document.getElementById('tencent-secretId').value); GM_setValue('tencent_secretKey', document.getElementById('tencent-secretKey').value); GM_setValue('tencent_appId', document.getElementById('tencent-appId').value); // 保存标贝 GM_setValue('biaobei_apiKey', document.getElementById('biaobei-apiKey').value); // 保存思必驰 GM_setValue('sbc_apiKey', document.getElementById('sbc-apiKey').value); // 保存出门问问 GM_setValue('cwmw_apiKey', document.getElementById('cwmw-apiKey').value); // 更新本地配置 CONFIG.volc.ak = document.getElementById('volc-ak').value; CONFIG.volc.sk = document.getElementById('volc-sk').value; CONFIG.volc.appId = document.getElementById('volc-appId').value; CONFIG.tencent.secretId = document.getElementById('tencent-secretId').value; CONFIG.tencent.secretKey = document.getElementById('tencent-secretKey').value; CONFIG.tencent.appId = document.getElementById('tencent-appId').value; CONFIG.biaobei.apiKey = document.getElementById('biaobei-apiKey').value; CONFIG.sbc.apiKey = document.getElementById('sbc-apiKey').value; CONFIG.cwmw.apiKey = document.getElementById('cwmw-apiKey').value; showToast('配置保存成功'); }); } // 初始化UI renderUI(); })();