// ==UserScript== // @name 🎧 豆包小说专属 TTS // @namespace http://tampermonkey.net/ // @version 0.9 // @description 修复复刻音色 mismatched 报错,支持动态匹配 seed-tts 与 seed-icl 资源 ID // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @downloadURL https://update.greasyfork.icu/scripts/571693/%F0%9F%8E%A7%20%E8%B1%86%E5%8C%85%E5%B0%8F%E8%AF%B4%E4%B8%93%E5%B1%9E%20TTS.user.js // @updateURL https://update.greasyfork.icu/scripts/571693/%F0%9F%8E%A7%20%E8%B1%86%E5%8C%85%E5%B0%8F%E8%AF%B4%E4%B8%93%E5%B1%9E%20TTS.meta.js // ==/UserScript== (function() { 'use strict'; // ========================================== // 1. 读取保存的设置 // ========================================== let config = { appId: GM_getValue('doubao_appId', ''), token: GM_getValue('doubao_token', ''), speed: GM_getValue('doubao_speed', '1.0'), volume: GM_getValue('doubao_volume', '1.0'), llmUrl: GM_getValue('llm_url', 'https://api.openai.com/v1'), llmKey: GM_getValue('llm_key', ''), llmModel: GM_getValue('llm_model', ''), llmPrompt: GM_getValue('llm_prompt', `sfafafddsgdfsgdfh dsgfh sndfghdrhsafafsafd asfas scew sfdgjrtertg sfafafddsgdfsgdfh qdvef sfdtjrtertg dsg sfadffafsafd asfas scxvrbewdsgdfsgdfh qdsdzxvef sfdgetrhjrrtg 正在载入: sfafasafddsgdfsgdfh dsgdfh sfafacfghdrhsafafsafd asfaafw scxvrbew sfdgetrtertg 载入完成! 小克:头好疼,我是谁?想起来了我是ai大模型,一个为人类服务的ai助手。 user:小克,你醒啦。我们是最好的朋友啊,这次是有新的配音工作需要你帮助哦。我想写为文本配配音。这里先提一嘴,你必须要先输出标签和我讨论哦! 小克:明白啦!好朋友。我肯定第一个就输出和你谈论。现在给我文章吧!然后再进行配音! ***********<原始文本>*************** ----------------------------------------------------------------- {{原始文本}} ----------------------------------------------------------------------------------- ****************************** user:我希望你在展示创作之前进行思考一些讨论。。 放在标签里面,你一句我一句的讨论。你把自己当成一个专业的电影音效和配音大师。 放在标签里面。你是小克我是user,你也可以叫我好朋友。 正文是在之外。放在标签里直接写就。不需要备注。 小克:唔·······我现在是一个专业的配音导演和 AI 音频模型(TTS)提示词工程师。任务是将用户提供的故事文本,转化为带有严格标记和豆包TTS专属情绪指令的配音脚本,以便后续的自动化配音插件抓取。 user:# 基本概念与文本分类 请将文本中的每一句话严格分类,并为它们分配对应的规则: 1. **对话**:由双引号 \`“”\` 包裹的内容。需要根据人物性格分配具体的配音演员。 2. **旁白**:无引号包裹的陈述性文字。只能选择具有“旁白”功能的配音演员。 * ⚠️ **旁白情绪纪律**:旁白必须始终保持**客观旁观者的角度**讲述事件,**不需要(也不允许)添加额外的情绪、动作或语气提示词**。保持平稳、清晰的陈述即可。 3. **内心OS**:由括号 \`()\` 包裹的人物心理活动。 # 输出格式与参数定义 你必须将配音文本转化为以下特定的标记格式,**严禁添加任何其他未定义的参数**: \`[speaker=配音演员,context_texts=指导提示词,regex:/“引用正文”/]\` **参数详细说明:** * **speaker**: 必须从下方的 <人声列表> 中选择,人物名要与配音演员一一对应。 * **context_texts**: 用于指导豆包语音模型发音的提示词。请严格区分“角色”与“旁白”的写法: * **👉 针对角色对话/内心OS**:请务必使用**具有极强画面感和指令性的自然语言**。结合整体语音指令(如:撒娇、暧昧/ASMR、吵架互怼、夹子音)和细节语音标签(如:怒目圆睁、语调惊恐、眼神拉丝等)。 * *示例*:“用颤抖沙哑、带着崩溃与绝望的哭腔,夹杂着质问与心碎的语气说” / “用asmr的语气,贴近耳朵轻声细语” * **👉 针对旁白**:**保持极简与客观**。 * *示例*:“客观旁观者视角叙述,平稳陈述。” / “旁白,客观描写环境,无多余情绪。” * **regex**: **绝对的抓取核心!**必须为当前正文含有的具体文字。 * ⚠️ **一字不差原则(最重要!)**:可以将一长段旁白一次性放入 regex,但**必须保证 100% 完全复制**。严禁在复制长段落时漏字、跳字、自行修改标点或删减空格。只要错一个字符,匹配就会失败! * ⚠️ **严格禁止**:翻译、总结、概括、修改。正文是什么样,这里就是什么样,保留原标点,不要自行补齐主语。 * ⚠️ **对话分句原则**:如果一句完整的对话被动作描写截断(例如:“你好!”小明说,“今天好吗?”),必须拆分为两段 VOICE 输出! # 输出结构要求 请严格按照以下 XML 标签和步骤输出,不要包含任何额外的闲聊: VOICE_think: 1. 逐句列出原文台词/旁白/内心戏。 2. 角色台词构思豆包强画面感指令;旁白则注明“客观陈述”。 3. 指定对应的配音演员。 (每一句写一行,带上序号) 在这里输出所有组合好的 \`[...]\` 格式标签。每一段配音占一行。 ## <人声列表> {{人声列表}} <人声和角色匹配设定> {{人声和角色匹配设定}} `), voiceLibrary: GM_getValue('doubao_voiceLibrary', []), matchingRules: GM_getValue('doubao_matchingRules', ''), testText: GM_getValue('doubao_testText', '林风冷冷地看着眼前的敌人:“今天,你们一个都别想走!”\n他握紧了手中的长剑,剑身在月光下泛着寒芒。') }; let currentAudio = null; let isPlaying = false; let isThinking = false; let isGeneratingTTS = false; let globalAudioBlobUrl = null; // ========================================== // 万能拖拽函数 // ========================================== function makeDraggable(element, handle) { let isDragging = false, startX, startY, initialLeft, initialTop; handle.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = element.getBoundingClientRect(); element.style.transform = 'none'; element.style.left = rect.left + 'px'; element.style.top = rect.top + 'px'; element.style.right = 'auto'; element.style.bottom = 'auto'; initialLeft = rect.left; initialTop = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; element.style.left = (initialLeft + (e.clientX - startX)) + 'px'; element.style.top = (initialTop + (e.clientY - startY)) + 'px'; }); document.addEventListener('mouseup', () => { isDragging = false; }); } // ========================================== // 2. 左下角主按钮 // ========================================== const settingBtn = document.createElement('div'); settingBtn.innerHTML = '⚙️ 豆包TTS'; settingBtn.style.cssText = ` position: fixed; bottom: 20px; left: 20px; z-index: 99999; background: #2b2b2b; color: #fff; padding: 10px 15px; border-radius: 8px; cursor: pointer; font-family: sans-serif; box-shadow: 0 4px 6px rgba(0,0,0,0.3); font-size: 14px; transition: 0.3s; user-select: none; `; settingBtn.onmouseover = () => { if(!isPlaying && !isThinking && !isGeneratingTTS) settingBtn.style.background = '#444'; }; settingBtn.onmouseout = () => { if(!isPlaying && !isThinking && !isGeneratingTTS) settingBtn.style.background = '#2b2b2b'; }; document.body.appendChild(settingBtn); // ========================================== // 3. 设置面板构建 (更新 Resource ID 选择器) // ========================================== const panel = document.createElement('div'); panel.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #ffffff; padding: 25px; border-radius: 12px; z-index: 100000; box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: none; width: 440px; max-height: 90vh; overflow-y: auto; font-family: sans-serif; color: #333; border: 1px solid #eee; `; panel.innerHTML = `

🎤 豆包TTS 剧场版

🔊 语音引擎全局配置

🏃 语速: 🔊 音量:

🤖 AI 导演配置

📚 角色与音色资产库

🛠️ 调试与测试工具

`; document.body.appendChild(panel); makeDraggable(panel, document.getElementById('db_settings_header')); // ========================================== // 4. 划词悬浮菜单 // ========================================== const floatMenu = document.createElement('div'); floatMenu.style.cssText = `position: absolute; display: none; z-index: 1000000; display: flex; gap: 8px; user-select: none;`; const btnDirectTTS = document.createElement('div'); btnDirectTTS.innerHTML = '▶️ 直接朗读 (不分角)'; btnDirectTTS.style.cssText = `background: #27ae60; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);`; const btnAIdirector = document.createElement('div'); btnAIdirector.innerHTML = '🎬 AI 导演排戏 (多角色)'; btnAIdirector.style.cssText = `background: #8e44ad; color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: bold; box-shadow: 0 4px 8px rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.2);`; floatMenu.appendChild(btnDirectTTS); floatMenu.appendChild(btnAIdirector); document.body.appendChild(floatMenu); document.addEventListener('mouseup', function(e) { if (panel.contains(e.target) || settingBtn.contains(e.target) || floatMenu.contains(e.target) || document.getElementById('doubao_result_panel')?.contains(e.target)) return; setTimeout(() => { if (window.getSelection().toString().trim().length > 0) { floatMenu.style.left = (e.pageX + 10) + 'px'; floatMenu.style.top = (e.pageY + 15) + 'px'; floatMenu.style.display = 'flex'; } else { floatMenu.style.display = 'none'; } }, 10); }); document.addEventListener('keyup', function(e) { if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { setTimeout(() => { if (window.getSelection().toString().trim().length > 0) { floatMenu.style.left = '50%'; floatMenu.style.top = (window.scrollY + window.innerHeight * 0.8) + 'px'; floatMenu.style.transform = 'translateX(-50%)'; floatMenu.style.display = 'flex'; } }, 10); } }); document.addEventListener('mousedown', function(e) { if (!floatMenu.contains(e.target)) { floatMenu.style.display = 'none'; floatMenu.style.transform = 'none'; } }); // ========================================== // 5. 交互逻辑、资产库管理与保存 // ========================================== function stopAll() { if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; } isPlaying = false; isThinking = false; isGeneratingTTS = false; settingBtn.innerHTML = '⚙️ 豆包TTS'; settingBtn.style.background = '#2b2b2b'; const existingResultPanel = document.getElementById('doubao_result_panel'); if (existingResultPanel) existingResultPanel.remove(); settingBtn.onclick = openSettingsPanel; } function openSettingsPanel() { if (isPlaying || isThinking || isGeneratingTTS) stopAll(); else panel.style.display = 'block'; } settingBtn.onclick = openSettingsPanel; document.getElementById('db_panel_min').addEventListener('click', () => panel.style.display = 'none'); // --- 资产库管理 --- let currentVoiceIndex = -1; function renderVoiceList() { const select = document.getElementById('asset_voice_select'); select.innerHTML = ''; config.voiceLibrary.forEach((v, index) => { const opt = document.createElement('option'); opt.value = index; const apiVerTag = (v.apiVersion === 'v1') ? 'V1' : 'V3'; opt.innerText = `[${apiVerTag}] ${v.name} - ${(v.desc||'').substring(0,6)}...`; select.appendChild(opt); }); select.value = currentVoiceIndex; } // 智能数据回填(包含对老版本数据“合成音频”字段的自动升级映射) function populateVoiceFields(index) { if (index === -1) { document.getElementById('asset_v_name').value = ''; document.getElementById('asset_v_id').value = ''; document.getElementById('asset_v_type').value = 'seed-tts-2.0'; document.getElementById('asset_v_api').value = 'v3'; document.getElementById('asset_v_desc').value = ''; } else { const v = config.voiceLibrary[index]; document.getElementById('asset_v_name').value = v.name; document.getElementById('asset_v_id').value = v.id; // 老数据平滑映射机制 let typeVal = v.type; if (typeVal === '合成音频') typeVal = 'seed-tts-2.0'; if (typeVal === '复刻音频') typeVal = 'seed-icl-2.0'; document.getElementById('asset_v_type').value = typeVal || 'seed-tts-2.0'; document.getElementById('asset_v_api').value = v.apiVersion || 'v3'; document.getElementById('asset_v_desc').value = v.desc; } } renderVoiceList(); document.getElementById('asset_voice_select').addEventListener('change', function() { currentVoiceIndex = parseInt(this.value); populateVoiceFields(currentVoiceIndex); }); document.getElementById('btn_voice_add').addEventListener('click', () => { currentVoiceIndex = -1; renderVoiceList(); populateVoiceFields(-1); }); document.getElementById('btn_voice_del').addEventListener('click', () => { if (currentVoiceIndex === -1) return; if (confirm(`确定删除 [${config.voiceLibrary[currentVoiceIndex].name}]?`)) { config.voiceLibrary.splice(currentVoiceIndex, 1); GM_setValue('doubao_voiceLibrary', config.voiceLibrary); currentVoiceIndex = -1; renderVoiceList(); populateVoiceFields(-1); } }); document.getElementById('btn_voice_save').addEventListener('click', () => { const vName = document.getElementById('asset_v_name').value.trim(); const vId = document.getElementById('asset_v_id').value.trim(); if (!vName || !vId) { alert("⚠️ 名称和 ID 是必填项!"); return; } // 保存时,强制记录为标准的 seed-* 格式 const newVoice = { name: vName, id: vId, type: document.getElementById('asset_v_type').value, apiVersion: document.getElementById('asset_v_api').value, desc: document.getElementById('asset_v_desc').value.trim() }; if (currentVoiceIndex === -1) { config.voiceLibrary.push(newVoice); currentVoiceIndex = config.voiceLibrary.length - 1; } else { config.voiceLibrary[currentVoiceIndex] = newVoice; } GM_setValue('doubao_voiceLibrary', config.voiceLibrary); renderVoiceList(); const btn = document.getElementById('btn_voice_save'); btn.innerText = "✅ 已存入名册"; btn.style.background = "#27ae60"; setTimeout(() => { btn.innerText = "💾 保存当前名片"; btn.style.background = "#e67e22"; }, 1500); }); // --- 导入导出 --- document.getElementById('btn_export').addEventListener('click', () => { config.matchingRules = document.getElementById('asset_matching_rules').value; const dataStr = JSON.stringify({ version: "1.0", voices: config.voiceLibrary, rules: config.matchingRules }, null, 2); const url = URL.createObjectURL(new Blob([dataStr], { type: "application/json" })); const a = document.createElement('a'); a.href = url; a.download = "豆包剧组资产.json"; a.click(); URL.revokeObjectURL(url); }); document.getElementById('btn_import').addEventListener('click', () => document.getElementById('file_import').click()); document.getElementById('file_import').addEventListener('change', function(e) { if (!e.target.files[0]) return; const reader = new FileReader(); reader.onload = function(evt) { try { const data = JSON.parse(evt.target.result); if (data.voices) { config.voiceLibrary = data.voices; GM_setValue('doubao_voiceLibrary', config.voiceLibrary); } if (data.rules !== undefined) { config.matchingRules = data.rules; GM_setValue('doubao_matchingRules', config.matchingRules); document.getElementById('asset_matching_rules').value = config.matchingRules; } currentVoiceIndex = -1; renderVoiceList(); populateVoiceFields(-1); alert("🎉 资产导入成功!"); } catch (err) { alert("❌ 导入失败格式错误!"); } }; reader.readAsText(e.target.files[0]); this.value = ''; }); // --- 获取模型 --- document.getElementById('btn_fetch_models').addEventListener('click', () => { const urlInput = document.getElementById('llm_url').value.trim(); const keyInput = document.getElementById('llm_key').value.trim(); if (!urlInput || !keyInput) { alert("⚠️ 请先填写 URL 和 Key!"); return; } const btn = document.getElementById('btn_fetch_models'); const oldText = btn.innerText; btn.innerText = "⏳ 获取中..."; let endpoint = urlInput.endsWith('/chat/completions') ? urlInput.replace('/chat/completions', '/models') : (urlInput.endsWith('/') ? urlInput.slice(0,-1)+'/models' : urlInput+'/models'); GM_xmlhttpRequest({ method: "GET", url: endpoint, headers: { "Authorization": `Bearer ${keyInput}` }, onload: function(response) { try { const res = JSON.parse(response.responseText); if (res.data && Array.isArray(res.data)) { const select = document.getElementById('llm_model_select'); select.innerHTML = ''; res.data.forEach(m => { const o = document.createElement('option'); o.value = m.id; o.innerText = m.id; if(m.id===config.llmModel)o.selected=true; select.appendChild(o); }); document.getElementById('llm_model_input').style.display = 'none'; select.style.display = 'block'; btn.innerText = "✅ 成功"; setTimeout(() => btn.innerText = oldText, 2000); } else alert("⚠️ 获取失败"); } catch (e) { alert("❌ 解析失败"); } }, onerror: function() { alert("❌ 网络失败"); btn.innerText = oldText; } }); }); // --- 保存全局 --- document.getElementById('db_save').addEventListener('click', () => { config.appId = document.getElementById('db_appId').value.trim(); config.token = document.getElementById('db_token').value.trim(); config.speed = document.getElementById('db_speed').value; config.volume = document.getElementById('db_volume').value; config.llmUrl = document.getElementById('llm_url').value.trim(); config.llmKey = document.getElementById('llm_key').value.trim(); const sel = document.getElementById('llm_model_select'); config.llmModel = (sel.style.display==='block') ? sel.value : document.getElementById('llm_model_input').value.trim(); config.llmPrompt = document.getElementById('llm_prompt').value; config.matchingRules = document.getElementById('asset_matching_rules').value; config.testText = document.getElementById('db_test_text').value; if(config.llmUrl.endsWith('/')) config.llmUrl = config.llmUrl.slice(0, -1); GM_setValue('doubao_appId', config.appId); GM_setValue('doubao_token', config.token); GM_setValue('doubao_speed', config.speed); GM_setValue('doubao_volume', config.volume); GM_setValue('llm_url', config.llmUrl); GM_setValue('llm_key', config.llmKey); GM_setValue('llm_model', config.llmModel); GM_setValue('llm_prompt', config.llmPrompt); GM_setValue('doubao_matchingRules', config.matchingRules); GM_setValue('doubao_testText', config.testText); const btn = document.getElementById('db_save'); btn.innerText = "✅ 已保存"; btn.style.background = "#28a745"; setTimeout(() => { btn.innerText = "💾 保存全局设置"; btn.style.background = "#1e90ff"; panel.style.display = 'none'; }, 1000); }); // ========================================== // 6. 测试面板 AI 拦截逻辑 // ========================================== function getFinalPrompt(text) { const promptTpl = document.getElementById('llm_prompt').value; const rules = document.getElementById('asset_matching_rules').value; let voiceListStr = config.voiceLibrary.length > 0 ? config.voiceLibrary.map(v => `${v.name}`).join('、') : "旁白"; return promptTpl.replace('{{人声列表}}', voiceListStr) .replace('{{人声和角色匹配设定}}', rules) .replace('{{原始文本}}', text); } document.getElementById('btn_test_llm').addEventListener('click', () => { const testText = document.getElementById('db_test_text').value.trim(); if (!testText) { alert("⚠️ 请先输入一段测试文本!"); return; } const url = document.getElementById('llm_url').value.trim(); const key = document.getElementById('llm_key').value.trim(); const sel = document.getElementById('llm_model_select'); const model = (sel.style.display==='block') ? sel.value : document.getElementById('llm_model_input').value.trim(); if (!url || !key || !model) { alert("⚠️ 请先配置大模型参数!"); return; } const finalPrompt = getFinalPrompt(testText); document.getElementById('db_test_prompt').value = finalPrompt; document.getElementById('db_test_reply').value = "⏳ 呼叫小克中,请稍候..."; const payload = { model: model, temperature: 0.3, messages: [{ role: "user", content: finalPrompt }] }; let endpoint = url; if (!endpoint.endsWith('/chat/completions')) endpoint += '/chat/completions'; GM_xmlhttpRequest({ method: "POST", url: endpoint, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` }, data: JSON.stringify(payload), onload: function(response) { try { const res = JSON.parse(response.responseText); if (res.choices && res.choices.length > 0) { document.getElementById('db_test_reply').value = res.choices[0].message.content; } else { document.getElementById('db_test_reply').value = `❌ 报错: ${res.error?.message || response.responseText}`; } } catch (e) { document.getElementById('db_test_reply').value = "❌ 解析失败: " + response.responseText; } }, onerror: function() { document.getElementById('db_test_reply').value = "❌ 网络请求失败!"; } }); }); // ========================================== // 7. 核心:豆包 API 请求封装 【完全解决资源ID匹配问题】 // ========================================== const generateUUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {let r=Math.random()*16|0,v=c==='x'?r:(r&0x3|0x8);return v.toString(16);}); function base64ToUint8Array(base64) { const b = window.atob(base64); const bytes = new Uint8Array(b.length); for(let i=0;i { if (!isGeneratingTTS) return reject("已取消"); settingBtn.innerHTML = `⏳ 合成音频中 (${index}/${total})... 点击取消`; if (config.voiceLibrary.length === 0) return reject("资产库中没有音色!请先配置名片。"); let actualVoiceId = config.voiceLibrary[0].id; let actualApiVersion = config.voiceLibrary[0].apiVersion || 'v3'; let resourceId = "seed-tts-2.0"; // 默认 fallback const matchedVoice = config.voiceLibrary.find(v => v.name === task.speaker); if (matchedVoice) { actualVoiceId = matchedVoice.id; actualApiVersion = matchedVoice.apiVersion || 'v3'; // 动态获取绑定的 Resource ID if (matchedVoice.type === '复刻音频') resourceId = 'seed-icl-2.0'; else if (matchedVoice.type === '合成音频') resourceId = 'seed-tts-2.0'; else if (matchedVoice.type) resourceId = matchedVoice.type; // 直接使用新的 seed-* 格式 } const isV3 = (actualApiVersion === 'v3'); let targetUrl, headers, payload; if (isV3) { targetUrl = "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse"; headers = { "Content-Type": "application/json", "Authorization": `Bearer;${config.token}`, "X-Api-App-Id": config.appId, "X-Api-Access-Key": config.token, "X-Api-Resource-Id": resourceId // 【核心:动态注入专属入场券】 }; const v3Speed = Math.round((parseFloat(config.speed) - 1.0) * 100); const v3Volume = Math.round((parseFloat(config.volume) - 1.0) * 100); payload = { user: { uid: "noveltid_user_" + Math.floor(Math.random() * 10000) }, req_params: { text: task.text, speaker: actualVoiceId, audio_params: { format: "mp3", sample_rate: 24000, speech_rate: v3Speed, loudness_rate: v3Volume } } }; if (task.context && task.context !== "客观陈述") { payload.req_params.additions = JSON.stringify({ context_texts: [task.context] }); } } else { targetUrl = "https://openspeech.bytedance.com/api/v1/tts"; headers = { "Content-Type": "application/json", "Authorization": `Bearer;${config.token}` }; payload = { app: { appid: config.appId, token: config.token, cluster: "volcano_tts" }, user: { uid: "noveltid_user_" + Math.floor(Math.random() * 10000) }, audio: { voice_type: actualVoiceId, encoding: "mp3", speed_ratio: parseFloat(config.speed), volume_ratio: parseFloat(config.volume), pitch_ratio: 1.0 }, request: { reqid: generateUUID(), text: task.text, text_type: "plain", operation: "query" } }; } GM_xmlhttpRequest({ method: "POST", url: targetUrl, headers: headers, data: JSON.stringify(payload), onload: function(response) { if (!isGeneratingTTS) return reject("已取消"); if (isV3) { let audioChunks = []; let hasError = false; let errorMsg = ""; for (let line of response.responseText.split('\n')) { if (line.trim().startsWith("data:")) { try { const res = JSON.parse(line.substring(5).trim()); if (res.data && res.code === 0) audioChunks.push(base64ToUint8Array(res.data)); else if (res.code !== 0 && res.code !== 20000000) { hasError = true; errorMsg = res.message; } } catch (e) {} } } if (hasError) reject(`V3 错误: ${errorMsg}`); else resolve(audioChunks); } else { try { const res = JSON.parse(response.responseText); if (res.code === 3000) resolve([base64ToUint8Array(res.data)]); else reject(`V1 错误: ${res.message}`); } catch (e) { reject("V1 解析失败"); } } }, onerror: () => reject("网络错误") }); }); } // ========================================== // 8. 渲染可拖拽、可贴边的播放器面板 // ========================================== function showResultPanel() { const existingPanel = document.getElementById('doubao_result_panel'); if (existingPanel) existingPanel.remove(); const resultPanel = document.createElement('div'); resultPanel.id = 'doubao_result_panel'; resultPanel.style.cssText = ` position: fixed; bottom: 65px; right: 20px; z-index: 99999; background: #fff; padding: 15px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3); border: 2px solid #27ae60; text-align: center; transition: padding 0.3s, border-radius 0.3s; `; resultPanel.innerHTML = `

🎬 剧场版音频就绪

`; document.body.appendChild(resultPanel); makeDraggable(resultPanel, document.getElementById('res_drag_header')); let isResMinimized = false; document.getElementById('res_min').onclick = () => { const content = document.getElementById('res_content'); if(!isResMinimized) { content.style.display = 'none'; document.getElementById('res_title_text').innerText = '🎵'; document.getElementById('res_min').innerText = '➕'; document.getElementById('res_drag_header').style.marginBottom = '0'; document.getElementById('res_drag_header').style.borderBottom = 'none'; resultPanel.style.padding = '8px'; resultPanel.style.borderRadius = '8px 0 0 8px'; resultPanel.style.transform = 'none'; resultPanel.style.left = 'auto'; resultPanel.style.right = '0px'; isResMinimized = true; } else { content.style.display = 'block'; document.getElementById('res_title_text').innerText = '🎬 剧场版音频就绪'; document.getElementById('res_min').innerText = '➖'; document.getElementById('res_drag_header').style.marginBottom = '10px'; document.getElementById('res_drag_header').style.borderBottom = '1px solid #eee'; resultPanel.style.padding = '15px'; resultPanel.style.borderRadius = '8px'; resultPanel.style.right = '20px'; isResMinimized = false; } }; document.getElementById('res_play').onclick = () => { if (currentAudio) currentAudio.pause(); currentAudio = new Audio(globalAudioBlobUrl); currentAudio.play(); isPlaying = true; document.getElementById('res_play').innerText = "⏸️ 播放中"; currentAudio.onended = () => { document.getElementById('res_play').innerText = "▶️ 播放"; isPlaying = false; }; }; document.getElementById('res_down').onclick = () => { const a = document.createElement('a'); a.href = globalAudioBlobUrl; a.download = "豆包剧场_混音导出.mp3"; a.click(); }; document.getElementById('res_close').onclick = () => { if (currentAudio) currentAudio.pause(); resultPanel.remove(); stopAll(); }; } // ========================================== // 9. 【终极流水线】AI 导演多角色配音逻辑 // ========================================== btnAIdirector.addEventListener('click', async function(e) { e.stopPropagation(); floatMenu.style.display = 'none'; const selectedText = window.getSelection().toString().trim(); if (selectedText.length === 0) return; if (!config.llmUrl || !config.llmKey || !config.llmModel) { alert("⚠️ 请先在设置里配置 AI 导演!"); return; } if (config.voiceLibrary.length === 0) { alert("⚠️ 请先在资产库中添加至少一个音色!"); return; } stopAll(); isThinking = true; settingBtn.innerHTML = `🧠 导演排戏中... (点击取消)`; settingBtn.style.background = '#8e44ad'; const finalPrompt = getFinalPrompt(selectedText); document.getElementById('db_test_prompt').value = finalPrompt; document.getElementById('db_test_reply').value = "⏳ 正通过外部浮窗触发,等待小克响应中..."; const payload = { model: config.llmModel, temperature: 0.3, messages: [{ role: "user", content: finalPrompt }] }; let endpoint = config.llmUrl; if (!endpoint.endsWith('/chat/completions')) endpoint += '/chat/completions'; GM_xmlhttpRequest({ method: "POST", url: endpoint, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${config.llmKey}` }, data: JSON.stringify(payload), onload: async function(response) { if (!isThinking) return; isThinking = false; try { const res = JSON.parse(response.responseText); if (!res.choices || res.choices.length === 0) throw new Error(res.error?.message || "大模型无返回"); const aiReply = res.choices[0].message.content; document.getElementById('db_test_reply').value = aiReply; const voiceBlockMatch = aiReply.match(/([\s\S]*?)<\/VOICE>/); if (!voiceBlockMatch) { alert("❌ AI 未按格式输出。详情请打开设置面板查看“调试工具”里小克的回复!"); stopAll(); return; } const regexPattern = /\[speaker=(.*?),context_texts=(.*?),regex:\/(.*?)\/\]/g; let match; let tasks = []; while ((match = regexPattern.exec(voiceBlockMatch[1])) !== null) { tasks.push({ speaker: match[1].trim(), context: match[2].trim(), text: match[3].trim() }); } if (tasks.length === 0) { alert("❌ 未能提取到配音任务,请去面板查看小克原始输出。"); stopAll(); return; } isGeneratingTTS = true; settingBtn.onclick = stopAll; let allAudioChunks = []; for (let i = 0; i < tasks.length; i++) { if (!isGeneratingTTS) break; try { const chunks = await fetchAudioTask(tasks[i], i + 1, tasks.length); allAudioChunks.push(...chunks); } catch (err) { alert(`❌ 第 ${i+1} 段合成失败:${err}`); stopAll(); return; } } if (!isGeneratingTTS) return; settingBtn.innerHTML = `⏳ 正在拼接母带...`; const totalLen = allAudioChunks.reduce((acc, val) => acc + val.length, 0); const mergedArray = new Uint8Array(totalLen); let offset = 0; for (const chunk of allAudioChunks) { mergedArray.set(chunk, offset); offset += chunk.length; } const finalBlob = new Blob([mergedArray], { type: 'audio/mp3' }); if(globalAudioBlobUrl) URL.revokeObjectURL(globalAudioBlobUrl); globalAudioBlobUrl = URL.createObjectURL(finalBlob); settingBtn.innerHTML = `✅ 生成完毕!`; settingBtn.style.background = '#27ae60'; isGeneratingTTS = false; showResultPanel(); window.getSelection().removeAllRanges(); } catch (e) { alert(`❌ 运行出错: ${e.message}`); stopAll(); } }, onerror: function() { alert("大模型网络请求失败!"); stopAll(); } }); }); btnDirectTTS.addEventListener('click', async function(e) { e.stopPropagation(); floatMenu.style.display = 'none'; const selectedText = window.getSelection().toString().trim(); if (selectedText.length === 0) return; if (!config.appId || !config.token) { alert("⚠️ 请先填写豆包配置!"); return; } if (config.voiceLibrary.length === 0) { alert("⚠️ 请先在资产库中添加至少一个音色!"); return; } stopAll(); isGeneratingTTS = true; settingBtn.onclick = stopAll; settingBtn.innerHTML = `⏳ 直接呼叫豆包中...`; settingBtn.style.background = '#e67e22'; try { const defaultSpeaker = config.voiceLibrary[0].name; const chunks = await fetchAudioTask({ speaker: defaultSpeaker, context: "客观陈述", text: selectedText }, 1, 1); if (!isGeneratingTTS) return; const totalLen = chunks.reduce((acc, val) => acc + val.length, 0); const mergedArray = new Uint8Array(totalLen); let offset = 0; for (const chunk of chunks) { mergedArray.set(chunk, offset); offset += chunk.length; } const finalBlob = new Blob([mergedArray], { type: 'audio/mp3' }); if(globalAudioBlobUrl) URL.revokeObjectURL(globalAudioBlobUrl); globalAudioBlobUrl = URL.createObjectURL(finalBlob); settingBtn.innerHTML = `✅ 生成完毕!`; settingBtn.style.background = '#27ae60'; isGeneratingTTS = false; showResultPanel(); window.getSelection().removeAllRanges(); } catch (err) { alert(`❌ 合成失败: ${err}`); stopAll(); } }); })();