├── .env.example ├── LICENSE ├── README.md ├── _headers ├── _routes.json ├── api ├── check-password.js ├── tts.js ├── verify-password.js └── voices.js ├── functions └── api │ ├── check-password.js │ ├── tts.js │ ├── verify-password.js │ └── voices.js ├── image └── TTS.png ├── index.html ├── script.js ├── speakers.json ├── style.css └── vercel.json /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibreSpark/LibreTTS/76719e9672b7f8a4f3521505a13f8634c8910f79/.env.example -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LibreTTS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LibreTTS - 在线文本转语音工具 2 | 3 | LibreTTS 是一款免费的在线文本转语音工具,支持多种声音选择,可调节语速和语调,提供即时试听和下载功能。 4 | 5 | > 本项目曾用名 Ciallo TTS。 6 | 7 | ## 功能特点 8 | 9 | - 🎯 支持超过300种不同语言和口音的声音 10 | - 🔊 实时预览和试听功能 11 | - ⚡ 支持长文本自动分段处理 12 | - 🎛️ 可调节语速和语调 13 | - 📱 响应式设计,支持移动端 14 | - 💾 支持音频下载 15 | - 📝 历史记录功能(最多保存50条) 16 | - 🔌 支持添加自定义OpenAI格式的TTS API 17 | 18 |
19 | 20 | 21 | 22 |
23 | 24 | ## API 说明 25 | 26 | 本项目提供以下 API 端点: 27 | 28 | ### Edge API 路径 29 | 30 | - `/api/tts` - 文本转语音 API 31 | - 支持 GET/POST 方法 32 | - GET 示例: `/api/tts?t=你好世界&v=zh-CN-XiaoxiaoNeural&r=0&p=0` 33 | - POST 示例: 请求体为JSON格式 `{"text": "你好世界", "voice": "zh-CN-XiaoxiaoNeural", "rate": 0, "pitch": 0}` 34 | 35 | - `/api/voices` - 获取可用语音列表 API 36 | - 仅支持 GET 方法 37 | - 示例: `/api/voices?l=zh&f=1` (l参数用于筛选语言,f参数指定返回格式) 38 | 39 | 例如:`https://libretts.is-an.org/api/tts` 40 | 41 | ### 自定义 API 42 | 43 | LibreTTS 支持添加自定义 API 端点,目前支持两种格式: 44 | 45 | #### OpenAI 格式 API 46 | 47 | - 支持与 OpenAI TTS API 兼容的服务,如 OpenAI、LMStudio、LocalAI 等 48 | - 请求格式: POST 49 | ```json 50 | { 51 | "model": "tts-1", 52 | "input": "您好,这是一段测试文本", 53 | "voice": "alloy", 54 | "response_format": "mp3" 55 | } 56 | ``` 57 | - 可选参数:`instructions` - 语音风格指导 58 | 59 | #### Edge 格式 API 60 | 61 | - 支持与 Microsoft Edge TTS API 兼容的服务 62 | - 请求格式: POST 63 | ```json 64 | { 65 | "text": "您好,这是一段测试文本", 66 | "voice": "zh-CN-XiaoxiaoNeural", 67 | "rate": 0, 68 | "pitch": 0 69 | } 70 | ``` 71 | 72 | #### 如何添加自定义 API 73 | 74 | 1. 点击界面上的"管理API"按钮 75 | 2. 填写以下信息: 76 | - API 名称:自定义名称 77 | - API 端点:语音生成服务地址 78 | - API 密钥:可选,用于授权 79 | - 模型列表端点:可选,用于获取可用模型 80 | - API 格式:选择 OpenAI 或 Edge 格式 81 | - 手动输入讲述人列表:逗号分隔的讲述人列表 82 | - 最大文本长度:可选,限制单次请求的文本长度 83 | 84 | 3. 点击"获取模型"按钮可自动填充可用讲述人列表 85 | 4. 点击"保存"完成添加 86 | 87 | #### 导入/导出 API 配置 88 | 89 | - 导出:将所有自定义 API 配置导出为 JSON 文件 90 | - 导入:从 JSON 文件导入 API 配置 91 | 92 | ## 部署指南 93 | 94 | ### Vercel 部署 95 | 96 | 1. Fork 本仓库到你的 GitHub 账号 97 | 98 | 2. 登录 [Vercel](https://vercel.com/),点击 "New Project" 99 | 100 | 3. 导入你 fork 的仓库,并选择默认设置部署即可 101 | 102 | 4. 部署完成后,你会获得一个 `your-project.vercel.app` 的域名 103 | 104 | ### Cloudflare Pages 部署 105 | 106 | 1. Fork 本仓库到你的 GitHub 账号 107 | 108 | 2. 登录 Cloudflare Dashboard,进入 Pages 页面 109 | 110 | 3. 创建新项目,选择从 Git 导入: 111 | - 选择你 fork 的仓库 112 | - 构建设置: 113 | - 构建命令:留空 114 | - 输出目录:`/` 115 | - 环境变量:无需设置 116 | 117 | 4. 部署完成后,你会获得一个 `xxx.pages.dev` 的域名 118 | 119 | ## 环境变量 120 | 121 | 除了原有配置外,现在项目支持设置环境变量 PASSWORD 来开启访问密码验证。如果 PASSWORD 非空,则用户第一次访问页面时会显示密码输入界面,输入正确后在该设备上后续访问将不再需要验证。 122 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Access-Control-Allow-Origin: * 3 | Access-Control-Allow-Methods: GET, POST, OPTIONS 4 | Access-Control-Allow-Headers: Content-Type, x-auth-token 5 | -------------------------------------------------------------------------------- /_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": [], 5 | "routes": [ 6 | { 7 | "pattern": "/api/tts", 8 | "function": "api/tts", 9 | "methods": ["GET", "POST", "OPTIONS"] 10 | }, 11 | { 12 | "pattern": "/api/voices", 13 | "function": "api/voices", 14 | "methods": ["GET", "OPTIONS"] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /api/check-password.js: -------------------------------------------------------------------------------- 1 | export default function handler(req, res) { 2 | const envPass = process.env.PASSWORD || ''; 3 | res.status(200).json({ requirePassword: !!envPass }); 4 | } 5 | -------------------------------------------------------------------------------- /api/tts.js: -------------------------------------------------------------------------------- 1 | let expiredAt = null; 2 | let endpoint = null; 3 | let clientId = "76a75279-2ffa-4c3d-8db8-7b47252aa41c"; 4 | 5 | export default async function handler(req, res) { 6 | // Set CORS headers 7 | res.setHeader("Access-Control-Allow-Origin", "*"); 8 | res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 9 | res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-auth-token"); 10 | 11 | // Handle OPTIONS request (preflight) 12 | if (req.method === "OPTIONS") { 13 | return res.status(204).end(); 14 | } 15 | 16 | try { 17 | if (req.method === "POST") { 18 | const body = req.body; 19 | const text = body.text || ""; 20 | const voiceName = body.voice || "zh-CN-XiaoxiaoMultilingualNeural"; 21 | const rate = Number(body.rate) || 0; 22 | const pitch = Number(body.pitch) || 0; 23 | const outputFormat = body.format || "audio-24khz-48kbitrate-mono-mp3"; 24 | const download = body.preview === false; 25 | 26 | return await handleTTS(res, text, voiceName, rate, pitch, outputFormat, download); 27 | } else if (req.method === "GET") { 28 | const { query } = req; 29 | const text = query.t || ""; 30 | const voiceName = query.v || "zh-CN-XiaoxiaoMultilingualNeural"; 31 | const rate = Number(query.r) || 0; 32 | const pitch = Number(query.p) || 0; 33 | const outputFormat = query.o || "audio-24khz-48kbitrate-mono-mp3"; 34 | const download = query.d === "true"; 35 | 36 | return await handleTTS(res, text, voiceName, rate, pitch, outputFormat, download); 37 | } else { 38 | return res.status(405).json({ error: "Method not allowed" }); 39 | } 40 | } catch (error) { 41 | console.error("API Error:", error); 42 | return res.status(500).json({ error: error.message || "Internal Server Error" }); 43 | } 44 | } 45 | 46 | async function handleTTS(res, text, voiceName, rate, pitch, outputFormat, download) { 47 | try { 48 | await refreshEndpoint(); 49 | 50 | // Generate SSML 51 | const ssml = generateSsml(text, voiceName, rate, pitch); 52 | 53 | // Get URL from endpoint 54 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 55 | 56 | // Set up headers 57 | const headers = { 58 | "Authorization": endpoint.t, 59 | "Content-Type": "application/ssml+xml", 60 | "X-Microsoft-OutputFormat": outputFormat, 61 | "User-Agent": "okhttp/4.5.0", 62 | "Origin": "https://azure.microsoft.com", 63 | "Referer": "https://azure.microsoft.com/" 64 | }; 65 | 66 | // Make the request to Microsoft's TTS service 67 | const response = await fetch(url, { 68 | method: "POST", 69 | headers: headers, 70 | body: ssml 71 | }); 72 | 73 | // Handle errors 74 | if (!response.ok) { 75 | throw new Error(`TTS 请求失败,状态码 ${response.status}`); 76 | } 77 | 78 | // Set appropriate headers 79 | res.setHeader("Content-Type", "audio/mpeg"); 80 | 81 | if (download) { 82 | res.setHeader("Content-Disposition", `attachment; filename="${voiceName}.mp3"`); 83 | } 84 | 85 | // Get the audio data and send it 86 | const audioData = await response.arrayBuffer(); 87 | const buffer = Buffer.from(audioData); 88 | return res.send(buffer); 89 | } catch (error) { 90 | console.error("TTS Error:", error); 91 | return res.status(500).json({ error: error.message }); 92 | } 93 | } 94 | 95 | function generateSsml(text, voiceName, rate, pitch) { 96 | return ` 97 | 98 | 99 | ${text} 100 | 101 | 102 | `; 103 | } 104 | 105 | async function refreshEndpoint() { 106 | if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { 107 | try { 108 | endpoint = await getEndpoint(); 109 | 110 | // Parse JWT token to get expiry time 111 | const parts = endpoint.t.split("."); 112 | if (parts.length >= 2) { 113 | const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); 114 | const jsonPayload = decodeURIComponent( 115 | atob(base64) 116 | .split('') 117 | .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) 118 | .join('') 119 | ); 120 | 121 | const decodedJwt = JSON.parse(jsonPayload); 122 | expiredAt = decodedJwt.exp; 123 | } else { 124 | // Default expiry if we can't parse the token 125 | expiredAt = (Date.now() / 1000) + 3600; 126 | } 127 | 128 | clientId = crypto.randomUUID ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(36).substring(2, 15); 129 | console.log(`获取 Endpoint, 过期时间剩余: ${((expiredAt - Date.now() / 1000) / 60).toFixed(2)} 分钟`); 130 | } catch (error) { 131 | console.error("无法获取或解析Endpoint:", error); 132 | throw error; 133 | } 134 | } else { 135 | console.log(`过期时间剩余: ${((expiredAt - Date.now() / 1000) / 60).toFixed(2)} 分钟`); 136 | } 137 | } 138 | 139 | async function getEndpoint() { 140 | const endpointUrl = "https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0"; 141 | const headers = { 142 | "Accept-Language": "zh-Hans", 143 | "X-ClientVersion": "4.0.530a 5fe1dc6c", 144 | "X-UserId": "0f04d16a175c411e", 145 | "X-HomeGeographicRegion": "zh-Hans-CN", 146 | "X-ClientTraceId": clientId || "76a75279-2ffa-4c3d-8db8-7b47252aa41c", 147 | "X-MT-Signature": await generateSignature(endpointUrl), 148 | "User-Agent": "okhttp/4.5.0", 149 | "Content-Type": "application/json; charset=utf-8", 150 | "Accept-Encoding": "gzip" 151 | }; 152 | 153 | const response = await fetch(endpointUrl, { 154 | method: "POST", 155 | headers: headers 156 | }); 157 | 158 | if (!response.ok) { 159 | throw new Error(`获取 Endpoint 失败,状态码 ${response.status}`); 160 | } 161 | 162 | return await response.json(); 163 | } 164 | 165 | async function generateSignature(urlStr) { 166 | try { 167 | const url = urlStr.split("://")[1]; 168 | const encodedUrl = encodeURIComponent(url); 169 | const uuidStr = crypto.randomUUID ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(36).substring(2, 15); 170 | const formattedDate = formatDate(); 171 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 172 | 173 | // Import the key for signing 174 | const keyData = base64ToArrayBuffer("oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=="); 175 | const key = await crypto.subtle.importKey( 176 | 'raw', 177 | keyData, 178 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 179 | false, 180 | ['sign'] 181 | ); 182 | 183 | // Sign the data 184 | const signature = await crypto.subtle.sign( 185 | 'HMAC', 186 | key, 187 | new TextEncoder().encode(bytesToSign) 188 | ); 189 | 190 | // Convert the signature to base64 191 | const signatureBase64 = arrayBufferToBase64(signature); 192 | 193 | return `MSTranslatorAndroidApp::${signatureBase64}::${formattedDate}::${uuidStr}`; 194 | } catch (error) { 195 | console.error("Generate signature error:", error); 196 | throw error; 197 | } 198 | } 199 | 200 | function formatDate() { 201 | const date = new Date(); 202 | const utcString = date.toUTCString().replace(/GMT/, "").trim() + " GMT"; 203 | return utcString.toLowerCase(); 204 | } 205 | 206 | // Helper functions 207 | function base64ToArrayBuffer(base64) { 208 | const binary_string = atob(base64); 209 | const len = binary_string.length; 210 | const bytes = new Uint8Array(len); 211 | for (let i = 0; i < len; i++) { 212 | bytes[i] = binary_string.charCodeAt(i); 213 | } 214 | return bytes.buffer; 215 | } 216 | 217 | function arrayBufferToBase64(buffer) { 218 | let binary = ''; 219 | const bytes = new Uint8Array(buffer); 220 | const len = bytes.byteLength; 221 | for (let i = 0; i < len; i++) { 222 | binary += String.fromCharCode(bytes[i]); 223 | } 224 | return btoa(binary); 225 | } 226 | 227 | function atob(str) { 228 | return Buffer.from(str, 'base64').toString('binary'); 229 | } 230 | 231 | function btoa(str) { 232 | return Buffer.from(str, 'binary').toString('base64'); 233 | } 234 | -------------------------------------------------------------------------------- /api/verify-password.js: -------------------------------------------------------------------------------- 1 | export default function handler(req, res) { 2 | if (req.method === 'POST') { 3 | const provided = req.body.password; 4 | const envPass = process.env.PASSWORD || ''; 5 | if (envPass && provided === envPass) { 6 | return res.status(200).json({ valid: true }); 7 | } else { 8 | return res.status(401).json({ valid: false, error: 'Invalid password' }); 9 | } 10 | } 11 | return res.status(405).json({ error: 'Method not allowed' }); 12 | } -------------------------------------------------------------------------------- /api/voices.js: -------------------------------------------------------------------------------- 1 | export default async function handler(req, res) { 2 | // Set CORS headers 3 | res.setHeader("Access-Control-Allow-Origin", "*"); 4 | res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); 5 | res.setHeader("Access-Control-Allow-Headers", "Content-Type, x-auth-token"); 6 | 7 | // Handle OPTIONS request 8 | if (req.method === "OPTIONS") { 9 | return res.status(204).end(); 10 | } 11 | 12 | // Only allow GET requests 13 | if (req.method !== "GET") { 14 | return res.status(405).json({ error: "Method not allowed" }); 15 | } 16 | 17 | try { 18 | const { query } = req; 19 | const localeFilter = (query.l || "").toLowerCase(); 20 | const format = query.f; 21 | 22 | let voices = await voiceList(); 23 | if (localeFilter) { 24 | voices = voices.filter(item => item.Locale.toLowerCase().includes(localeFilter)); 25 | } 26 | 27 | if (format === "0") { 28 | const formattedVoices = voices.map(item => formatVoiceItem(item)); 29 | res.setHeader("Content-Type", "text/plain; charset=utf-8"); 30 | return res.send(formattedVoices.join("\n")); 31 | } else if (format === "1") { 32 | const voiceMap = Object.fromEntries(voices.map(item => [item.ShortName, item.LocalName])); 33 | return res.json(voiceMap); 34 | } else { 35 | return res.json(voices); 36 | } 37 | } catch (error) { 38 | console.error("API Error:", error); 39 | return res.status(500).json({ error: error.message || "Failed to fetch voices" }); 40 | } 41 | } 42 | 43 | function formatVoiceItem(item) { 44 | return ` 45 | - !!org.nobody.multitts.tts.speaker.Speaker 46 | avatar: '' 47 | code: ${item.ShortName} 48 | desc: '' 49 | extendUI: '' 50 | gender: ${item.Gender === "Female" ? "0" : "1"} 51 | name: ${item.LocalName} 52 | note: 'wpm: ${item.WordsPerMinute || ""}' 53 | param: '' 54 | sampleRate: ${item.SampleRateHertz || "24000"} 55 | speed: 1.5 56 | type: 1 57 | volume: 1`; 58 | } 59 | 60 | async function voiceList() { 61 | const headers = { 62 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 63 | "X-Ms-Useragent": "SpeechStudio/2021.05.001", 64 | "Content-Type": "application/json", 65 | "Origin": "https://azure.microsoft.com", 66 | "Referer": "https://azure.microsoft.com" 67 | }; 68 | 69 | const response = await fetch("https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list", { 70 | headers: headers 71 | }); 72 | 73 | if (!response.ok) { 74 | throw new Error(`获取语音列表失败,状态码 ${response.status}`); 75 | } 76 | 77 | return await response.json(); 78 | } 79 | -------------------------------------------------------------------------------- /functions/api/check-password.js: -------------------------------------------------------------------------------- 1 | export async function onRequest(context) { 2 | const { env } = context; 3 | 4 | // 检查是否设置了密码环境变量 5 | const passwordRequired = !!env.PASSWORD; 6 | 7 | // 返回是否需要密码的信息 8 | return new Response(JSON.stringify({ 9 | requirePassword: passwordRequired 10 | }), { 11 | headers: { 12 | "Content-Type": "application/json", 13 | "Access-Control-Allow-Origin": "*", 14 | "Access-Control-Allow-Methods": "GET, OPTIONS", 15 | "Access-Control-Allow-Headers": "Content-Type" 16 | } 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /functions/api/tts.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | let expiredAt = null; 3 | let endpoint = null; 4 | let clientId = "76a75279-2ffa-4c3d-8db8-7b47252aa41c"; 5 | 6 | // Simplified handler for Cloudflare Pages 7 | export async function onRequest(context) { 8 | const request = context.request; 9 | const url = new URL(request.url); 10 | const path = url.pathname.replace(/^\/api/, ''); 11 | 12 | // Handle CORS preflight requests 13 | if (request.method === "OPTIONS") { 14 | return new Response(null, { 15 | status: 204, 16 | headers: { 17 | ...makeCORSHeaders(), 18 | "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS", 19 | "Access-Control-Allow-Headers": "Content-Type, x-auth-token" 20 | } 21 | }); 22 | } 23 | 24 | try { 25 | // Handle API endpoints 26 | if (path === '/tts') { 27 | if (request.method === "POST") { 28 | const body = await request.json(); 29 | const text = body.text || ""; 30 | const voiceName = body.voice || "zh-CN-XiaoxiaoMultilingualNeural"; 31 | const rate = Number(body.rate) || 0; 32 | const pitch = Number(body.pitch) || 0; 33 | const outputFormat = body.format || "audio-24khz-48kbitrate-mono-mp3"; 34 | const download = body.preview === false; 35 | 36 | return await handleTTS(text, voiceName, rate, pitch, outputFormat, download); 37 | } else if (request.method === "GET") { 38 | const text = url.searchParams.get("t") || ""; 39 | const voiceName = url.searchParams.get("v") || "zh-CN-XiaoxiaoMultilingualNeural"; 40 | const rate = Number(url.searchParams.get("r")) || 0; 41 | const pitch = Number(url.searchParams.get("p")) || 0; 42 | const outputFormat = url.searchParams.get("o") || "audio-24khz-48kbitrate-mono-mp3"; 43 | const download = url.searchParams.get("d") === "true"; 44 | 45 | return await handleTTS(text, voiceName, rate, pitch, outputFormat, download); 46 | } else { 47 | return new Response(JSON.stringify({ 48 | error: "Method not allowed" 49 | }), { 50 | status: 405, 51 | headers: { 52 | "Content-Type": "application/json", 53 | ...makeCORSHeaders() 54 | } 55 | }); 56 | } 57 | } else if (path === '/voices') { 58 | if (request.method === "GET") { 59 | return await handleVoices(url); 60 | } else { 61 | return new Response(JSON.stringify({ 62 | error: "Method not allowed" 63 | }), { 64 | status: 405, 65 | headers: { 66 | "Content-Type": "application/json", 67 | ...makeCORSHeaders() 68 | } 69 | }); 70 | } 71 | } else { 72 | return new Response(getDefaultHTML(url), { 73 | status: 200, 74 | headers: { 75 | "Content-Type": "text/html; charset=utf-8", 76 | ...makeCORSHeaders() 77 | } 78 | }); 79 | } 80 | } catch (error) { 81 | console.error("API Error:", error); 82 | return new Response(JSON.stringify({ 83 | error: error.message || "Internal Server Error" 84 | }), { 85 | status: 500, 86 | headers: { 87 | "Content-Type": "application/json", 88 | ...makeCORSHeaders() 89 | } 90 | }); 91 | } 92 | } 93 | 94 | async function handleTTS(text, voiceName, rate, pitch, outputFormat, download) { 95 | try { 96 | await refreshEndpoint(); 97 | 98 | // Generate SSML 99 | const ssml = generateSsml(text, voiceName, rate, pitch); 100 | 101 | // Get URL from endpoint 102 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 103 | 104 | // Set up headers 105 | const headers = { 106 | "Authorization": endpoint.t, 107 | "Content-Type": "application/ssml+xml", 108 | "X-Microsoft-OutputFormat": outputFormat, 109 | "User-Agent": "okhttp/4.5.0", 110 | "Origin": "https://azure.microsoft.com", 111 | "Referer": "https://azure.microsoft.com/" 112 | }; 113 | 114 | // Make the request to Microsoft's TTS service 115 | const response = await fetch(url, { 116 | method: "POST", 117 | headers: headers, 118 | body: ssml 119 | }); 120 | 121 | // Handle errors 122 | if (!response.ok) { 123 | throw new Error(`TTS 请求失败,状态码 ${response.status}`); 124 | } 125 | 126 | // Create a new response with the appropriate headers 127 | const responseHeaders = new Headers({ 128 | "Content-Type": "audio/mpeg", 129 | ...makeCORSHeaders() 130 | }); 131 | 132 | if (download) { 133 | responseHeaders.set("Content-Disposition", `attachment; filename="${voiceName}.mp3"`); 134 | } 135 | 136 | const audioData = await response.arrayBuffer(); 137 | return new Response(audioData, { 138 | status: 200, 139 | headers: responseHeaders 140 | }); 141 | } catch (error) { 142 | console.error("TTS Error:", error); 143 | return new Response(JSON.stringify({ 144 | error: error.message 145 | }), { 146 | status: 500, 147 | headers: { 148 | "Content-Type": "application/json", 149 | ...makeCORSHeaders() 150 | } 151 | }); 152 | } 153 | } 154 | 155 | async function handleVoices(url) { 156 | try { 157 | const localeFilter = (url.searchParams.get("l") || "").toLowerCase(); 158 | const format = url.searchParams.get("f"); 159 | 160 | let voices = await voiceList(); 161 | if (localeFilter) { 162 | voices = voices.filter(item => item.Locale.toLowerCase().includes(localeFilter)); 163 | } 164 | 165 | if (format === "0") { 166 | const formattedVoices = voices.map(item => formatVoiceItem(item)); 167 | return new Response(formattedVoices.join("\n"), { 168 | headers: { 169 | "Content-Type": "text/plain; charset=utf-8", 170 | ...makeCORSHeaders() 171 | } 172 | }); 173 | } else if (format === "1") { 174 | const voiceMap = Object.fromEntries(voices.map(item => [item.ShortName, item.LocalName])); 175 | return new Response(JSON.stringify(voiceMap), { 176 | headers: { 177 | "Content-Type": "application/json; charset=utf-8", 178 | ...makeCORSHeaders() 179 | } 180 | }); 181 | } else { 182 | return new Response(JSON.stringify(voices), { 183 | headers: { 184 | "Content-Type": "application/json; charset=utf-8", 185 | ...makeCORSHeaders() 186 | } 187 | }); 188 | } 189 | } catch (error) { 190 | return new Response(JSON.stringify({ 191 | error: error.message || "Failed to fetch voices" 192 | }), { 193 | status: 500, 194 | headers: { 195 | "Content-Type": "application/json", 196 | ...makeCORSHeaders() 197 | } 198 | }); 199 | } 200 | } 201 | 202 | function generateSsml(text, voiceName, rate, pitch) { 203 | return ` 204 | 205 | 206 | ${text} 207 | 208 | 209 | `; 210 | } 211 | 212 | function formatVoiceItem(item) { 213 | return ` 214 | - !!org.nobody.multitts.tts.speaker.Speaker 215 | avatar: '' 216 | code: ${item.ShortName} 217 | desc: '' 218 | extendUI: '' 219 | gender: ${item.Gender === "Female" ? "0" : "1"} 220 | name: ${item.LocalName} 221 | note: 'wpm: ${item.WordsPerMinute || ""}' 222 | param: '' 223 | sampleRate: ${item.SampleRateHertz || "24000"} 224 | speed: 1.5 225 | type: 1 226 | volume: 1`; 227 | } 228 | 229 | async function voiceList() { 230 | const headers = { 231 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 232 | "X-Ms-Useragent": "SpeechStudio/2021.05.001", 233 | "Content-Type": "application/json", 234 | "Origin": "https://azure.microsoft.com", 235 | "Referer": "https://azure.microsoft.com" 236 | }; 237 | 238 | const response = await fetch("https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list", { 239 | headers: headers 240 | }); 241 | 242 | if (!response.ok) { 243 | throw new Error(`获取语音列表失败,状态码 ${response.status}`); 244 | } 245 | 246 | return await response.json(); 247 | } 248 | 249 | function makeCORSHeaders() { 250 | return { 251 | "Access-Control-Allow-Origin": "*", 252 | "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", 253 | "Access-Control-Allow-Headers": "Content-Type, x-auth-token", 254 | "Access-Control-Max-Age": "86400" 255 | }; 256 | } 257 | 258 | function getDefaultHTML(url) { 259 | const baseUrl = `${url.protocol}//${url.host}/api`; 260 | return ` 261 |
    262 |
  1. /tts?t=[text]&v=[voice]&r=[rate]&p=[pitch]&o=[outputFormat] 试试
  2. 263 |
  3. /voices?l=[locale, 如 zh|zh-CN]&f=[format, 0/1/空 0(TTS-Server)|1(MultiTTS)] 试试
  4. 264 |
265 | `; 266 | } 267 | 268 | async function refreshEndpoint() { 269 | if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { 270 | try { 271 | endpoint = await getEndpoint(); 272 | 273 | // Parse JWT token to get expiry time 274 | const parts = endpoint.t.split("."); 275 | if (parts.length >= 2) { 276 | const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); 277 | const jsonPayload = decodeURIComponent( 278 | atob(base64) 279 | .split('') 280 | .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) 281 | .join('') 282 | ); 283 | 284 | const decodedJwt = JSON.parse(jsonPayload); 285 | expiredAt = decodedJwt.exp; 286 | } else { 287 | // Default expiry if we can't parse the token 288 | expiredAt = (Date.now() / 1000) + 3600; 289 | } 290 | 291 | clientId = crypto.randomUUID ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(36).substring(2, 15); 292 | console.log(`获取 Endpoint, 过期时间剩余: ${((expiredAt - Date.now() / 1000) / 60).toFixed(2)} 分钟`); 293 | } catch (error) { 294 | console.error("无法获取或解析Endpoint:", error); 295 | throw error; 296 | } 297 | } else { 298 | console.log(`过期时间剩余: ${((expiredAt - Date.now() / 1000) / 60).toFixed(2)} 分钟`); 299 | } 300 | } 301 | 302 | async function getEndpoint() { 303 | const endpointUrl = "https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0"; 304 | const headers = { 305 | "Accept-Language": "zh-Hans", 306 | "X-ClientVersion": "4.0.530a 5fe1dc6c", 307 | "X-UserId": "0f04d16a175c411e", 308 | "X-HomeGeographicRegion": "zh-Hans-CN", 309 | "X-ClientTraceId": clientId || "76a75279-2ffa-4c3d-8db8-7b47252aa41c", 310 | "X-MT-Signature": await generateSignature(endpointUrl), 311 | "User-Agent": "okhttp/4.5.0", 312 | "Content-Type": "application/json; charset=utf-8", 313 | "Accept-Encoding": "gzip" 314 | }; 315 | 316 | const response = await fetch(endpointUrl, { 317 | method: "POST", 318 | headers: headers 319 | }); 320 | 321 | if (!response.ok) { 322 | throw new Error(`获取 Endpoint 失败,状态码 ${response.status}`); 323 | } 324 | 325 | return await response.json(); 326 | } 327 | 328 | async function generateSignature(urlStr) { 329 | try { 330 | const url = urlStr.split("://")[1]; 331 | const encodedUrl = encodeURIComponent(url); 332 | const uuidStr = crypto.randomUUID ? crypto.randomUUID().replace(/-/g, "") : Math.random().toString(36).substring(2, 15); 333 | const formattedDate = formatDate(); 334 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 335 | 336 | // Import the key for signing 337 | const keyData = base64ToArrayBuffer("oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=="); 338 | const key = await crypto.subtle.importKey( 339 | 'raw', 340 | keyData, 341 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 342 | false, 343 | ['sign'] 344 | ); 345 | 346 | // Sign the data 347 | const signature = await crypto.subtle.sign( 348 | 'HMAC', 349 | key, 350 | new TextEncoder().encode(bytesToSign) 351 | ); 352 | 353 | // Convert the signature to base64 354 | const signatureBase64 = arrayBufferToBase64(signature); 355 | 356 | return `MSTranslatorAndroidApp::${signatureBase64}::${formattedDate}::${uuidStr}`; 357 | } catch (error) { 358 | console.error("Generate signature error:", error); 359 | throw error; 360 | } 361 | } 362 | 363 | function formatDate() { 364 | const date = new Date(); 365 | const utcString = date.toUTCString().replace(/GMT/, "").trim() + " GMT"; 366 | return utcString.toLowerCase(); 367 | } 368 | 369 | // Helper functions for Cloudflare environment 370 | function base64ToArrayBuffer(base64) { 371 | const binary_string = atob(base64); 372 | const len = binary_string.length; 373 | const bytes = new Uint8Array(len); 374 | for (let i = 0; i < len; i++) { 375 | bytes[i] = binary_string.charCodeAt(i); 376 | } 377 | return bytes.buffer; 378 | } 379 | 380 | function arrayBufferToBase64(buffer) { 381 | let binary = ''; 382 | const bytes = new Uint8Array(buffer); 383 | const len = bytes.byteLength; 384 | for (let i = 0; i < len; i++) { 385 | binary += String.fromCharCode(bytes[i]); 386 | } 387 | return btoa(binary); 388 | } 389 | -------------------------------------------------------------------------------- /functions/api/verify-password.js: -------------------------------------------------------------------------------- 1 | export async function onRequest(context) { 2 | const { request, env } = context; 3 | 4 | // 处理CORS预检请求 5 | if (request.method === "OPTIONS") { 6 | return new Response(null, { 7 | status: 204, 8 | headers: { 9 | "Access-Control-Allow-Origin": "*", 10 | "Access-Control-Allow-Methods": "POST, OPTIONS", 11 | "Access-Control-Allow-Headers": "Content-Type" 12 | } 13 | }); 14 | } 15 | 16 | // 只允许POST请求 17 | if (request.method !== "POST") { 18 | return new Response(JSON.stringify({ 19 | error: "Method not allowed" 20 | }), { 21 | status: 405, 22 | headers: { 23 | "Content-Type": "application/json", 24 | "Access-Control-Allow-Origin": "*" 25 | } 26 | }); 27 | } 28 | 29 | try { 30 | // 读取环境变量密码 31 | const correctPassword = env.PASSWORD; 32 | 33 | // 如果未设置密码,则直接验证通过 34 | if (!correctPassword) { 35 | return new Response(JSON.stringify({ 36 | valid: true, 37 | message: "No password required" 38 | }), { 39 | headers: { 40 | "Content-Type": "application/json", 41 | "Access-Control-Allow-Origin": "*" 42 | } 43 | }); 44 | } 45 | 46 | // 解析请求体获取用户提交的密码 47 | const { password } = await request.json(); 48 | 49 | // 验证密码 50 | if (password === correctPassword) { 51 | return new Response(JSON.stringify({ 52 | valid: true, 53 | message: "Password verified successfully" 54 | }), { 55 | headers: { 56 | "Content-Type": "application/json", 57 | "Access-Control-Allow-Origin": "*" 58 | } 59 | }); 60 | } else { 61 | return new Response(JSON.stringify({ 62 | valid: false, 63 | message: "Incorrect password" 64 | }), { 65 | status: 401, 66 | headers: { 67 | "Content-Type": "application/json", 68 | "Access-Control-Allow-Origin": "*" 69 | } 70 | }); 71 | } 72 | } catch (error) { 73 | return new Response(JSON.stringify({ 74 | error: "Bad request" 75 | }), { 76 | status: 400, 77 | headers: { 78 | "Content-Type": "application/json", 79 | "Access-Control-Allow-Origin": "*" 80 | } 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /functions/api/voices.js: -------------------------------------------------------------------------------- 1 | export async function onRequest(context) { 2 | const request = context.request; 3 | const url = new URL(request.url); 4 | 5 | // Handle CORS preflight requests 6 | if (request.method === "OPTIONS") { 7 | return new Response(null, { 8 | status: 204, 9 | headers: { 10 | ...makeCORSHeaders(), 11 | "Access-Control-Allow-Methods": "GET,OPTIONS", 12 | "Access-Control-Allow-Headers": "Content-Type, x-auth-token" 13 | } 14 | }); 15 | } 16 | 17 | // Only allow GET requests 18 | if (request.method !== "GET") { 19 | return new Response(JSON.stringify({ 20 | error: "Method not allowed" 21 | }), { 22 | status: 405, 23 | headers: { 24 | "Content-Type": "application/json", 25 | ...makeCORSHeaders() 26 | } 27 | }); 28 | } 29 | 30 | try { 31 | return await handleVoices(url); 32 | } catch (error) { 33 | console.error("API Error:", error); 34 | return new Response(JSON.stringify({ 35 | error: error.message || "Internal Server Error" 36 | }), { 37 | status: 500, 38 | headers: { 39 | "Content-Type": "application/json", 40 | ...makeCORSHeaders() 41 | } 42 | }); 43 | } 44 | } 45 | 46 | async function handleVoices(url) { 47 | try { 48 | const localeFilter = (url.searchParams.get("l") || "").toLowerCase(); 49 | const format = url.searchParams.get("f"); 50 | 51 | let voices = await voiceList(); 52 | if (localeFilter) { 53 | voices = voices.filter(item => item.Locale.toLowerCase().includes(localeFilter)); 54 | } 55 | 56 | if (format === "0") { 57 | const formattedVoices = voices.map(item => formatVoiceItem(item)); 58 | return new Response(formattedVoices.join("\n"), { 59 | headers: { 60 | "Content-Type": "text/plain; charset=utf-8", 61 | ...makeCORSHeaders() 62 | } 63 | }); 64 | } else if (format === "1") { 65 | const voiceMap = Object.fromEntries(voices.map(item => [item.ShortName, item.LocalName])); 66 | return new Response(JSON.stringify(voiceMap), { 67 | headers: { 68 | "Content-Type": "application/json; charset=utf-8", 69 | ...makeCORSHeaders() 70 | } 71 | }); 72 | } else { 73 | return new Response(JSON.stringify(voices), { 74 | headers: { 75 | "Content-Type": "application/json; charset=utf-8", 76 | ...makeCORSHeaders() 77 | } 78 | }); 79 | } 80 | } catch (error) { 81 | return new Response(JSON.stringify({ 82 | error: error.message || "Failed to fetch voices" 83 | }), { 84 | status: 500, 85 | headers: { 86 | "Content-Type": "application/json", 87 | ...makeCORSHeaders() 88 | } 89 | }); 90 | } 91 | } 92 | 93 | function formatVoiceItem(item) { 94 | return ` 95 | - !!org.nobody.multitts.tts.speaker.Speaker 96 | avatar: '' 97 | code: ${item.ShortName} 98 | desc: '' 99 | extendUI: '' 100 | gender: ${item.Gender === "Female" ? "0" : "1"} 101 | name: ${item.LocalName} 102 | note: 'wpm: ${item.WordsPerMinute || ""}' 103 | param: '' 104 | sampleRate: ${item.SampleRateHertz || "24000"} 105 | speed: 1.5 106 | type: 1 107 | volume: 1`; 108 | } 109 | 110 | async function voiceList() { 111 | const headers = { 112 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", 113 | "X-Ms-Useragent": "SpeechStudio/2021.05.001", 114 | "Content-Type": "application/json", 115 | "Origin": "https://azure.microsoft.com", 116 | "Referer": "https://azure.microsoft.com" 117 | }; 118 | 119 | const response = await fetch("https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list", { 120 | headers: headers 121 | }); 122 | 123 | if (!response.ok) { 124 | throw new Error(`获取语音列表失败,状态码 ${response.status}`); 125 | } 126 | 127 | return await response.json(); 128 | } 129 | 130 | function makeCORSHeaders() { 131 | return { 132 | "Access-Control-Allow-Origin": "*", 133 | "Access-Control-Allow-Methods": "GET, OPTIONS", 134 | "Access-Control-Allow-Headers": "Content-Type, x-auth-token", 135 | "Access-Control-Max-Age": "86400" 136 | }; 137 | } 138 | -------------------------------------------------------------------------------- /image/TTS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LibreSpark/LibreTTS/76719e9672b7f8a4f3521505a13f8634c8910f79/image/TTS.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 文本转语音 | 免费在线TTS转换工具 - LibreTTS 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 65 | 66 | 67 | 87 | 88 |
89 |
90 | 91 |
92 |
93 |
94 |

文本转语音

95 |
96 |
97 |
98 |
99 |
100 | 101 |
102 | 107 |
108 | 111 |
112 |
113 | 114 |
115 |
116 | 117 | 120 |
121 |
122 | 123 |
124 |
125 | 126 |
127 | 135 |
136 | 139 |
140 |
141 |
142 | 148 | 0% (0/100000单位) 149 |
150 | 151 | 152 | 158 | 159 | 170 | 171 | 172 |
173 | 174 | 175 |
176 | 177 |
178 | 179 | 180 |
181 | 182 | 183 | 184 | 185 | 186 |
187 | 188 |
189 | 190 | 下载语音文件 191 |
192 | 193 | 196 |
197 |
198 |
199 | 200 | 201 |
202 |
203 |
204 |

历史记录

205 |
206 |
207 | 208 |
209 |
210 |
211 |
212 |
213 |
214 | 215 | 216 | 324 | 325 | 335 | 336 | 337 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | let apiConfig; 2 | let lastRequestTime = 0; 3 | let currentAudioURL = null; 4 | let requestCounter = 0; 5 | let isGenerating = false; 6 | 7 | const API_CONFIG = { 8 | 'edge-api': { 9 | url: '/api/tts' 10 | }, 11 | 'oai-tts': { 12 | url: 'https://oai-tts.zwei.de.eu.org/v1/audio/speech' 13 | } 14 | }; 15 | 16 | // 在API_CONFIG对象之后添加 17 | let customAPIs = {}; 18 | let editingApiId = null; 19 | 20 | function loadSpeakers() { 21 | return $.ajax({ 22 | url: 'speakers.json', 23 | method: 'GET', 24 | dataType: 'json', 25 | success: function(data) { 26 | apiConfig = data; 27 | 28 | // 加载自定义API 29 | loadCustomAPIs(); 30 | 31 | // 更新API选择下拉菜单 32 | updateApiOptions(); 33 | 34 | // 设置默认API 35 | updateSpeakerOptions($('#api').val()); 36 | }, 37 | error: function(jqXHR, textStatus, errorThrown) { 38 | console.error(`加载讲述者失败:${textStatus} - ${errorThrown}`); 39 | showError('加载讲述者失败,请刷新页面重试。'); 40 | } 41 | }); 42 | } 43 | 44 | // 加载自定义API配置 45 | function loadCustomAPIs() { 46 | try { 47 | const savedAPIs = localStorage.getItem('customAPIs'); 48 | if (savedAPIs) { 49 | customAPIs = JSON.parse(savedAPIs); 50 | 51 | // 合并到API_CONFIG 52 | Object.keys(customAPIs).forEach(apiId => { 53 | API_CONFIG[apiId] = { 54 | url: customAPIs[apiId].endpoint, 55 | isCustom: true, 56 | apiKey: customAPIs[apiId].apiKey, 57 | format: customAPIs[apiId].format, 58 | manual: customAPIs[apiId].manual, 59 | maxLength: customAPIs[apiId].maxLength 60 | }; 61 | }); 62 | } 63 | } catch (error) { 64 | console.error('加载自定义API失败:', error); 65 | } 66 | } 67 | 68 | // 更新API选择下拉菜单 69 | function updateApiOptions() { 70 | const apiSelect = $('#api'); 71 | 72 | // 保存当前选择 73 | const currentApi = apiSelect.val(); 74 | 75 | // 清除除了内置选项之外的所有选项 76 | apiSelect.find('option:not([value="edge-api"]):not([value="oai-tts"])').remove(); 77 | 78 | // 添加自定义API选项 79 | Object.keys(customAPIs).forEach(apiId => { 80 | apiSelect.append(new Option(customAPIs[apiId].name, apiId)); 81 | }); 82 | 83 | // 如果之前选择的是有效的选项,则恢复选择 84 | if (currentApi && (currentApi === 'edge-api' || currentApi === 'oai-tts' || customAPIs[currentApi])) { 85 | apiSelect.val(currentApi); 86 | } 87 | } 88 | 89 | // 更新讲述者选项列表 90 | async function updateSpeakerOptions(apiName) { 91 | const speakerSelect = $('#speaker'); 92 | speakerSelect.empty().append(new Option('加载中...', '')); 93 | 94 | try { 95 | // 检查是否是自定义API 96 | if (customAPIs[apiName]) { 97 | const customApi = customAPIs[apiName]; 98 | 99 | // 如果有手动设置的讲述人列表,使用它 100 | if (customApi.manual && customApi.manual.length) { 101 | speakerSelect.empty(); 102 | customApi.manual.forEach(v => speakerSelect.append(new Option(v, v))); 103 | } 104 | // 如果有API密钥和模型端点,尝试获取讲述人 105 | else if (customApi.apiKey && customApi.modelEndpoint) { 106 | try { 107 | const speakers = await fetchCustomSpeakers(apiName); 108 | speakerSelect.empty(); 109 | 110 | if (Object.keys(speakers).length === 0) { 111 | speakerSelect.append(new Option('未找到讲述人,请手动添加', '')); 112 | } else { 113 | Object.entries(speakers).forEach(([key, value]) => { 114 | speakerSelect.append(new Option(value, key)); 115 | }); 116 | } 117 | } catch (error) { 118 | console.error('获取自定义讲述人失败:', error); 119 | speakerSelect.empty().append(new Option('获取讲述人失败,请手动添加', '')); 120 | } 121 | } else { 122 | speakerSelect.empty().append(new Option('请先获取模型或手动输入讲述人', '')); 123 | } 124 | } else if (apiConfig[apiName]) { 125 | // 使用预定义的speakers 126 | const speakers = apiConfig[apiName].speakers; 127 | speakerSelect.empty(); 128 | 129 | Object.entries(speakers).forEach(([key, value]) => { 130 | speakerSelect.append(new Option(value, key)); 131 | }); 132 | } else { 133 | throw new Error(`未知的API: ${apiName}`); 134 | } 135 | } catch (error) { 136 | console.error('加载讲述者失败:', error); 137 | speakerSelect.empty().append(new Option('加载讲述者失败', '')); 138 | showError(`加载讲述者失败: ${error.message}`); 139 | } 140 | 141 | // 更新API提示信息 142 | updateApiTipsText(apiName); 143 | } 144 | 145 | // 从自定义API获取讲述者 146 | async function fetchCustomSpeakers(apiId) { 147 | const customApi = customAPIs[apiId]; 148 | if (!customApi || !customApi.modelEndpoint) { 149 | return { 'default': '默认讲述者' }; 150 | } 151 | 152 | try { 153 | const headers = { 154 | 'Content-Type': 'application/json' 155 | }; 156 | 157 | // 如果有API密钥,添加授权头 158 | if (customApi.apiKey) { 159 | headers['Authorization'] = `Bearer ${customApi.apiKey}`; 160 | } 161 | 162 | const response = await fetch(customApi.modelEndpoint, { 163 | method: 'GET', 164 | headers: headers 165 | }); 166 | 167 | if (!response.ok) { 168 | throw new Error(`获取讲述者失败: ${response.status}`); 169 | } 170 | 171 | const data = await response.json(); 172 | 173 | // 处理OpenAI格式的响应 174 | if (data.data && Array.isArray(data.data)) { 175 | const ttsModels = data.data.filter(model => 176 | model.id.startsWith('tts-') || 177 | ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'].includes(model.id) 178 | ); 179 | 180 | if (ttsModels.length === 0) { 181 | return { 'default': '未找到TTS模型' }; 182 | } 183 | 184 | // 创建讲述者映射 185 | const speakerMap = {}; 186 | ttsModels.forEach(model => { 187 | speakerMap[model.id] = model.id; 188 | }); 189 | 190 | // 保存到apiConfig以便后续使用 191 | if (!apiConfig[apiId]) { 192 | apiConfig[apiId] = {}; 193 | } 194 | apiConfig[apiId].speakers = speakerMap; 195 | 196 | return speakerMap; 197 | } else { 198 | // 如果响应格式不匹配预期 199 | console.warn('API返回格式不是标准OpenAI格式:', data); 200 | return { 'default': '自定义讲述人' }; 201 | } 202 | } catch (error) { 203 | console.error('获取自定义讲述者失败:', error); 204 | return { 'error': `错误: ${error.message}` }; 205 | } 206 | } 207 | 208 | // 更新API提示文本 209 | function updateApiTipsText(apiName) { 210 | const tips = { 211 | 'edge-api': 'Edge API 请求应该不限次数', 212 | 'oai-tts': 'OpenAI-TTS 支持情感调整,不支持停顿标签' 213 | }; 214 | 215 | // 如果是自定义API 216 | if (customAPIs[apiName]) { 217 | const format = customAPIs[apiName].format || 'openai'; 218 | const formatStr = format === 'openai' ? 'OpenAI格式' : 'Edge API格式'; 219 | $('#apiTips').text(`自定义API: ${customAPIs[apiName].name} - 使用${formatStr}`); 220 | } else { 221 | $('#apiTips').text(tips[apiName] || ''); 222 | } 223 | 224 | // 根据API类型调整界面 225 | if (apiName === 'oai-tts' || (customAPIs[apiName] && customAPIs[apiName].format === 'openai')) { 226 | $('#instructionsContainer').show(); 227 | $('#formatContainer').show(); 228 | $('#rateContainer, #pitchContainer').hide(); 229 | $('#pauseControls').hide(); // 隐藏停顿控制 230 | } else { 231 | $('#instructionsContainer').hide(); 232 | $('#formatContainer').hide(); 233 | $('#rateContainer, #pitchContainer').show(); 234 | $('#pauseControls').show(); // 显示停顿控制 235 | } 236 | 237 | // 更新字符限制提示文本 238 | updateCharCountText(); 239 | } 240 | 241 | function updateSliderLabel(sliderId, labelId) { 242 | const slider = $(`#${sliderId}`); 243 | const label = $(`#${labelId}`); 244 | label.text(slider.val()); 245 | 246 | slider.off('input').on('input', function() { 247 | label.text(this.value); 248 | }); 249 | } 250 | 251 | $(document).ready(function() { 252 | // 确保默认API选择为edge-api 253 | if ($('#api').length && !$('#api').val()) { 254 | $('#api').val('edge-api'); 255 | } 256 | loadSpeakers().then(() => { 257 | $('#apiTips').text('Edge API 请求应该不限次数'); 258 | 259 | // 初始化音频播放器 260 | initializeAudioPlayer(); 261 | 262 | $('[data-toggle="tooltip"]').tooltip(); 263 | 264 | $('#api').on('change', function() { 265 | const apiName = $(this).val(); 266 | updateSpeakerOptions(apiName); 267 | 268 | $('#rate, #pitch').val(0); 269 | updateSliderLabel('rate', 'rateValue'); 270 | updateSliderLabel('pitch', 'pitchValue'); 271 | 272 | // 根据选择的API更新提示信息 273 | const tips = { 274 | 'edge-api': 'Edge API 请求应该不限次数', 275 | 'oai-tts': 'OpenAI-TTS 支持情感调整,不支持停顿标签' 276 | }; 277 | $('#apiTips').text(tips[apiName] || ''); 278 | 279 | // 根据API显示或隐藏instructions输入框和停顿功能 280 | if (apiName === 'oai-tts') { 281 | $('#instructionsContainer').show(); 282 | $('#formatContainer').show(); 283 | $('#rateContainer, #pitchContainer').hide(); 284 | $('#pauseControls').hide(); // 隐藏停顿控制 285 | 286 | // 更新字符限制提示文本 287 | updateCharCountText(); 288 | } else { 289 | $('#instructionsContainer').hide(); 290 | $('#formatContainer').hide(); 291 | $('#rateContainer, #pitchContainer').show(); 292 | $('#pauseControls').show(); // 显示停顿控制 293 | 294 | // 恢复默认字符限制提示文本 295 | updateCharCountText(); 296 | } 297 | }); 298 | 299 | updateSliderLabel('rate', 'rateValue'); 300 | updateSliderLabel('pitch', 'pitchValue'); 301 | 302 | $('#generateButton').on('click', function() { 303 | if (canMakeRequest()) { 304 | generateVoice(false); 305 | } else { 306 | showError('请稍候再试,3秒只能请求一次。'); 307 | } 308 | }); 309 | 310 | $('#previewButton').on('click', function() { 311 | if (canMakeRequest()) { 312 | generateVoice(true); 313 | } else { 314 | showError('请稍候再试,每3秒只能请求一次。'); 315 | } 316 | }); 317 | 318 | $('#text').on('input', function() { 319 | updateCharCountText(); 320 | }); 321 | 322 | // 添加插入停顿功能 323 | $('#insertPause').on('click', function() { 324 | const seconds = parseFloat($('#pauseSeconds').val()); 325 | if (isNaN(seconds) || seconds < 0.01 || seconds > 100) { 326 | showError('请输入0.01到100之间的数字'); 327 | return; 328 | } 329 | 330 | const textarea = $('#text')[0]; 331 | const cursorPos = textarea.selectionStart; 332 | const textBefore = textarea.value.substring(0, cursorPos); 333 | const textAfter = textarea.value.substring(textarea.selectionEnd); 334 | 335 | // 插入停顿标记 336 | const pauseTag = ``; 337 | textarea.value = textBefore + pauseTag + textAfter; 338 | 339 | // 恢复光标位置 340 | const newPos = cursorPos + pauseTag.length; 341 | textarea.setSelectionRange(newPos, newPos); 342 | textarea.focus(); 343 | }); 344 | 345 | // 限制输入数字范围 346 | $('#pauseSeconds').on('input', function() { 347 | let value = parseFloat($(this).val()); 348 | if (value > 100) $(this).val(100); 349 | if (value < 0.01 && value !== '') $(this).val(0.01); 350 | }); 351 | }); 352 | 353 | // 添加自定义API管理功能 354 | $('#manageApiBtn').on('click', function() { 355 | editingApiId = null; 356 | $('#customApiForm')[0].reset(); 357 | $('#apiFormat').val('openai'); 358 | $('#manualSpeakers').val(''); 359 | $('#maxLength').val(''); 360 | updateApiFormPlaceholders('openai'); // 初始化表单占位符 361 | refreshSavedApisList(); 362 | $('#apiManagerModal').modal('show'); 363 | }); 364 | 365 | // 监听API格式选择变化 366 | $('#apiFormat').on('change', function() { 367 | updateApiFormPlaceholders($(this).val()); 368 | }); 369 | 370 | $('#fetchModelsBtn').on('click', async function() { 371 | const endpoint = $('#apiEndpoint').val().trim(); 372 | const key = $('#apiKey').val().trim(); 373 | const modelUrl = $('#modelEndpoint').val().trim(); 374 | const apiFormat = $('#apiFormat').val(); 375 | 376 | if (!endpoint || !modelUrl) { 377 | showError('请先填写 API 端点和模型列表端点'); 378 | return; 379 | } 380 | 381 | try { 382 | const headers = {'Content-Type':'application/json'}; 383 | if (key) headers['Authorization'] = `Bearer ${key}`; 384 | const res = await fetch(modelUrl, {method:'GET', headers}); 385 | 386 | if (!res.ok) throw new Error(res.statusText); 387 | const data = await res.json(); 388 | 389 | let models = []; 390 | if (apiFormat === 'openai') { 391 | // OpenAI格式处理 392 | models = Array.isArray(data.data) 393 | ? data.data.map(m => m.id || m.name) 394 | : []; 395 | } else if (apiFormat === 'edge') { 396 | // Edge API格式处理 397 | models = Array.isArray(data) 398 | ? data.map(m => m.ShortName || m.name) 399 | : []; 400 | } 401 | 402 | if (models.length > 0) { 403 | $('#manualSpeakers').val(models.join(',')); 404 | showInfo(`成功获取到 ${models.length} 个模型`); 405 | } else { 406 | showWarning('未找到可用模型,请检查API格式是否正确'); 407 | } 408 | } catch (e) { 409 | showError('获取模型失败: ' + e.message); 410 | } 411 | }); 412 | 413 | $('#customApiForm').on('submit', function(e) { 414 | e.preventDefault(); 415 | const name = $('#apiName').val().trim(); 416 | const endpoint = $('#apiEndpoint').val().trim(); 417 | if (!name || !endpoint) { showError('API 名称和端点不能为空'); return; } 418 | const key = $('#apiKey').val().trim(); 419 | const modelEndpoint = $('#modelEndpoint').val().trim(); 420 | const format = $('#apiFormat').val(); 421 | const manual = $('#manualSpeakers').val().split(',').map(s=>s.trim()).filter(Boolean); 422 | const maxLen = parseInt($('#maxLength').val()) || null; 423 | const enableSegmentation = $('#enableSegmentation').prop('checked'); 424 | const id = editingApiId || ('custom-' + Date.now()); 425 | customAPIs[id] = { 426 | name, endpoint, apiKey:key, modelEndpoint, format, manual, 427 | maxLength: maxLen, enableSegmentation 428 | }; 429 | localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); 430 | API_CONFIG[id] = { 431 | url:endpoint, isCustom:true, apiKey:key, format, manual, 432 | maxLength: maxLen, enableSegmentation 433 | }; 434 | updateApiOptions(); 435 | refreshSavedApisList(); 436 | $('#customApiForm')[0].reset(); 437 | editingApiId = null; 438 | showInfo(`自定义API ${editingApiId? '已更新':'已添加'}: ${name}`); 439 | }); 440 | 441 | // 添加导出API配置功能 442 | $('#exportApisBtn').on('click', function() { 443 | if (Object.keys(customAPIs).length === 0) { 444 | showWarning('没有自定义API可导出'); 445 | return; 446 | } 447 | 448 | try { 449 | // 创建一个包含所有自定义API的JSON 450 | const exportData = { 451 | version: '1.0', 452 | timestamp: new Date().toISOString(), 453 | apis: customAPIs 454 | }; 455 | 456 | const dataStr = JSON.stringify(exportData, null, 2); 457 | const blob = new Blob([dataStr], {type: 'application/json'}); 458 | const url = URL.createObjectURL(blob); 459 | 460 | // 创建下载链接并触发下载 461 | const a = document.createElement('a'); 462 | a.download = `ciallo-tts-apis-${new Date().toISOString().slice(0,10)}.json`; 463 | a.href = url; 464 | a.click(); 465 | 466 | // 清理URL对象 467 | setTimeout(() => URL.revokeObjectURL(url), 100); 468 | 469 | showInfo(`成功导出 ${Object.keys(customAPIs).length} 个自定义API配置`); 470 | } catch (error) { 471 | console.error('导出API失败:', error); 472 | showError('导出失败: ' + error.message); 473 | } 474 | }); 475 | 476 | // 添加导入API配置功能 477 | $('#importApisBtn').on('click', function() { 478 | $('#importApisInput').click(); 479 | }); 480 | 481 | $('#importApisInput').on('change', function(e) { 482 | const file = e.target.files[0]; 483 | if (!file) return; 484 | 485 | const reader = new FileReader(); 486 | reader.onload = function(event) { 487 | try { 488 | const data = JSON.parse(event.target.result); 489 | 490 | // 验证导入的数据格式 491 | if (!data.apis || typeof data.apis !== 'object') { 492 | throw new Error('无效的API配置文件格式'); 493 | } 494 | 495 | // 计算有多少个API将被导入 496 | const apiCount = Object.keys(data.apis).length; 497 | 498 | if (apiCount === 0) { 499 | showWarning('导入的文件不包含任何API配置'); 500 | return; 501 | } 502 | 503 | // 确认导入 504 | if (confirm(`确定要导入 ${apiCount} 个自定义API配置吗?这将合并与现有配置。`)) { 505 | // 合并API配置 506 | let importedCount = 0; 507 | let updatedCount = 0; 508 | 509 | Object.entries(data.apis).forEach(([id, api]) => { 510 | // 生成新ID,避免覆盖现有配置 511 | const newId = id.startsWith('custom-') ? id : 'custom-' + Date.now() + '-' + importedCount; 512 | 513 | // 检查是否已存在相同名称和端点的API 514 | const existingApiId = Object.keys(customAPIs).find(apiId => 515 | customAPIs[apiId].name === api.name && 516 | customAPIs[apiId].endpoint === api.endpoint 517 | ); 518 | 519 | if (existingApiId) { 520 | // 更新现有API 521 | customAPIs[existingApiId] = { ...api }; 522 | API_CONFIG[existingApiId] = { 523 | url: api.endpoint, 524 | isCustom: true, 525 | apiKey: api.apiKey, 526 | format: api.format, 527 | manual: api.manual, 528 | maxLength: api.maxLength 529 | }; 530 | updatedCount++; 531 | } else { 532 | // 添加新API 533 | customAPIs[newId] = { ...api }; 534 | API_CONFIG[newId] = { 535 | url: api.endpoint, 536 | isCustom: true, 537 | apiKey: api.apiKey, 538 | format: api.format, 539 | manual: api.manual, 540 | maxLength: api.maxLength 541 | }; 542 | importedCount++; 543 | } 544 | }); 545 | 546 | // 保存到localStorage 547 | localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); 548 | 549 | // 更新UI 550 | updateApiOptions(); 551 | refreshSavedApisList(); 552 | 553 | showInfo(`导入完成: 新增 ${importedCount} 个API, 更新 ${updatedCount} 个API`); 554 | } 555 | } catch (error) { 556 | console.error('导入API失败:', error); 557 | showError('导入失败: ' + error.message); 558 | } 559 | 560 | // 重置文件输入,允许重复选择同一文件 561 | this.value = ''; 562 | }; 563 | 564 | reader.onerror = function() { 565 | showError('读取文件失败'); 566 | }; 567 | 568 | reader.readAsText(file); 569 | }); 570 | 571 | // 添加批量删除功能 572 | $('#batchDeleteBtn').on('click', function() { 573 | $('.api-selection-tools').show(); 574 | $('#batchDeleteBtn').hide(); 575 | $('#exportApisBtn, #importApisBtn').hide(); 576 | 577 | // 为每个API项添加复选框 578 | $('#savedApisList .list-group-item').each(function() { 579 | const apiId = $(this).find('.delete-api').data('api-id'); 580 | 581 | // 在每个API项前添加复选框 582 | $(this).prepend( 583 | `
584 | 585 |
` 586 | ); 587 | 588 | // 调整布局以适应复选框 589 | $(this).css('padding-left', '40px').css('position', 'relative'); 590 | 591 | // 隐藏原有的按钮 592 | $(this).find('.btn-group').hide(); 593 | }); 594 | }); 595 | 596 | // 全选功能 597 | $('#selectAllApis').on('change', function() { 598 | const isChecked = $(this).prop('checked'); 599 | $('.api-select').prop('checked', isChecked); 600 | }); 601 | 602 | // 取消选择 603 | $('#cancelSelectionBtn').on('click', function() { 604 | exitBatchDeleteMode(); 605 | }); 606 | 607 | // 删除选中项 608 | $('#deleteSelectedBtn').on('click', function() { 609 | const selectedIds = []; 610 | $('.api-select:checked').each(function() { 611 | selectedIds.push($(this).val()); 612 | }); 613 | 614 | if (selectedIds.length === 0) { 615 | showWarning('请先选择要删除的API'); 616 | return; 617 | } 618 | 619 | if (confirm(`确定要删除选中的 ${selectedIds.length} 个API吗?`)) { 620 | selectedIds.forEach(id => { 621 | delete customAPIs[id]; 622 | delete API_CONFIG[id]; 623 | }); 624 | 625 | // 更新localStorage 626 | localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); 627 | 628 | // 更新UI 629 | updateApiOptions(); 630 | 631 | // 如果当前选中的是被删除的API,切换到edge-api 632 | if (selectedIds.includes($('#api').val())) { 633 | $('#api').val('edge-api').trigger('change'); 634 | } 635 | 636 | showInfo(`已删除 ${selectedIds.length} 个自定义API`); 637 | 638 | // 退出批量删除模式 639 | exitBatchDeleteMode(); 640 | refreshSavedApisList(); 641 | } 642 | }); 643 | 644 | function exitBatchDeleteMode() { 645 | $('.api-selection-tools').hide(); 646 | $('#batchDeleteBtn').show(); 647 | $('#exportApisBtn, #importApisBtn').show(); 648 | $('.api-checkbox').remove(); 649 | $('#savedApisList .list-group-item').css('padding-left', '').css('position', ''); 650 | $('#savedApisList .list-group-item .btn-group').show(); 651 | $('#selectAllApis').prop('checked', false); 652 | } 653 | 654 | // 初始API选择变更事件 655 | $('#api').on('change', function() { 656 | const apiName = $(this).val(); 657 | updateSpeakerOptions(apiName); 658 | 659 | // 根据选择的API更新提示信息 660 | updateApiTipsText(apiName); 661 | }); 662 | }); 663 | 664 | // 刷新保存的自定义API列表 665 | function refreshSavedApisList() { 666 | const listContainer = $('#savedApisList'); 667 | listContainer.empty(); 668 | 669 | if (Object.keys(customAPIs).length === 0) { 670 | listContainer.append('
没有保存的自定义API
'); 671 | $('#batchDeleteBtn').hide(); 672 | return; 673 | } else { 674 | $('#batchDeleteBtn').show(); 675 | } 676 | 677 | Object.keys(customAPIs).forEach(apiId => { 678 | const api = customAPIs[apiId]; 679 | const item = $(` 680 |
681 |
682 |
${api.name}
683 |
684 | ${api.endpoint} 685 | ${api.format ? ` ${api.format === 'openai' ? 'OpenAI' : 'Edge'}` : ''} 686 | ${api.manual && api.manual.length ? ` ${api.manual.length}个讲述人` : ''} 687 |
688 |
689 |
690 | 693 | 696 | 699 |
700 |
701 | `); 702 | 703 | listContainer.append(item); 704 | }); 705 | 706 | // 添加删除API的事件处理程序 707 | $('.delete-api').on('click', function() { 708 | const apiId = $(this).data('api-id'); 709 | deleteCustomApi(apiId); 710 | }); 711 | 712 | // 添加编辑API的事件处理程序 713 | $('.edit-api').on('click', function() { 714 | const apiId = $(this).data('id'); 715 | const api = customAPIs[apiId]; 716 | editingApiId = apiId; 717 | $('#apiName').val(api.name); 718 | $('#apiEndpoint').val(api.endpoint); 719 | $('#apiKey').val(api.apiKey); 720 | $('#modelEndpoint').val(api.modelEndpoint); 721 | $('#apiFormat').val(api.format); 722 | $('#manualSpeakers').val((api.manual || []).join(',')); 723 | $('#maxLength').val(api.maxLength || ''); 724 | updateApiFormPlaceholders(api.format || 'openai'); 725 | }); 726 | 727 | // 添加复制API的事件处理程序 728 | $('.copy-api').on('click', function() { 729 | const apiId = $(this).data('id'); 730 | const api = customAPIs[apiId]; 731 | 732 | if (!api) return; 733 | 734 | const newId = 'custom-' + Date.now(); 735 | const apiCopy = {...api}; 736 | apiCopy.name = `${api.name} (复制)`; 737 | 738 | customAPIs[newId] = apiCopy; 739 | API_CONFIG[newId] = { 740 | url: apiCopy.endpoint, 741 | isCustom: true, 742 | apiKey: apiCopy.apiKey, 743 | format: apiCopy.format, 744 | manual: apiCopy.manual, 745 | maxLength: apiCopy.maxLength 746 | }; 747 | 748 | // 保存到localStorage 749 | localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); 750 | 751 | // 更新UI 752 | updateApiOptions(); 753 | refreshSavedApisList(); 754 | showInfo(`已复制API: ${apiCopy.name}`); 755 | }); 756 | } 757 | 758 | // 更新字符计数提示文本 759 | function updateCharCountText() { 760 | const currentLength = getTextLength($('#text').val()); 761 | const apiName = $('#api').val(); 762 | const { maxTotal } = getApiLimits(apiName); 763 | const percentage = Math.round((currentLength / maxTotal) * 100); 764 | 765 | $('#charCount').text(`${percentage}% (${currentLength}/${maxTotal}单位)`); 766 | $('#text').attr('maxlength', maxTotal); 767 | 768 | // 如果超过100%,阻止继续输入 769 | if (currentLength > maxTotal) { 770 | const textarea = $('#text')[0]; 771 | let text = textarea.value; 772 | // 截断文本直到符合长度限制 773 | while (getTextLength(text) > maxTotal && text.length > 0) { 774 | text = text.slice(0, -1); 775 | } 776 | textarea.value = text; 777 | $('#charCount').text(`100% (${getTextLength(text)}/${maxTotal}单位)`); 778 | } 779 | } 780 | 781 | function canMakeRequest() { 782 | if (isGenerating) { 783 | showError('请等待当前语音生成完成'); 784 | return false; 785 | } 786 | return true; 787 | } 788 | 789 | async function generateVoice(isPreview) { 790 | const apiName = $('#api').val(); 791 | const apiUrl = API_CONFIG[apiName].url; 792 | const text = $('#text').val().trim(); 793 | // 在开始生成时保存当前选择的讲述人名称 794 | const currentSpeakerText = $('#speaker option:selected').text(); 795 | // 保存当前选择的讲述人ID,用于后续所有分段请求 796 | const currentSpeakerId = $('#speaker').val(); 797 | 798 | if (!text) { 799 | showError('请输入要转换的文本'); 800 | return; 801 | } 802 | 803 | if (isPreview) { 804 | const previewText = text.substring(0, 20); 805 | try { 806 | const blob = await makeRequest(apiUrl, true, previewText, '', currentSpeakerId); 807 | if (blob) { 808 | if (currentAudioURL) URL.revokeObjectURL(currentAudioURL); 809 | currentAudioURL = URL.createObjectURL(blob); 810 | $('#result').show(); 811 | $('#audio').attr('src', currentAudioURL); 812 | $('#download').attr('href', currentAudioURL); 813 | } 814 | } catch (error) { 815 | showError('试听失败:' + error.message); 816 | } finally { 817 | // Use existing loading toast hide instead of overlay 818 | } 819 | return; 820 | } 821 | 822 | if (!canMakeRequest()) { 823 | return; 824 | } 825 | 826 | // 设置生成状态 827 | isGenerating = true; 828 | $('#generateButton').prop('disabled', true); 829 | $('#previewButton').prop('disabled', true); 830 | 831 | // 处理长文本 832 | const segments = splitText(text); 833 | requestCounter++; 834 | const currentRequestId = requestCounter; 835 | 836 | if (segments.length > 1) { 837 | showLoading(`正在生成#${currentRequestId}请求的 1/${segments.length} 段语音...`); 838 | generateVoiceForLongText(segments, currentRequestId, currentSpeakerText, currentSpeakerId, apiUrl, apiName).then(finalBlob => { 839 | if (finalBlob) { 840 | if (currentAudioURL) { 841 | URL.revokeObjectURL(currentAudioURL); 842 | } 843 | currentAudioURL = URL.createObjectURL(finalBlob); 844 | $('#result').show(); 845 | $('#audio').attr('src', currentAudioURL); 846 | $('#download').attr('href', currentAudioURL); 847 | } 848 | }).finally(() => { 849 | hideLoading(); 850 | isGenerating = false; // 重置生成状态 851 | $('#generateButton').prop('disabled', false); 852 | $('#previewButton').prop('disabled', false); 853 | }); 854 | } else { 855 | showLoading(`正在生成#${currentRequestId}请求的语音...`); 856 | const requestInfo = `#${currentRequestId}(1/1)`; 857 | makeRequest(apiUrl, false, text, requestInfo, currentSpeakerId) 858 | .then(blob => { 859 | if (blob) { 860 | const timestamp = new Date().toLocaleTimeString(); 861 | // 使用保存的讲述人名称,而不是重新获取 862 | const cleanText = text.replace(//g, ''); 863 | const shortenedText = cleanText.length > 7 ? cleanText.substring(0, 7) + '...' : cleanText; 864 | addHistoryItem(timestamp, currentSpeakerText, shortenedText, blob, requestInfo); 865 | } 866 | }) 867 | .finally(() => { 868 | hideLoading(); 869 | isGenerating = false; // 重置生成状态 870 | $('#generateButton').prop('disabled', false); 871 | $('#previewButton').prop('disabled', false); 872 | }); 873 | } 874 | } 875 | 876 | const cachedAudio = new Map(); 877 | 878 | function escapeXml(text) { 879 | // 临时替换 SSML 标签 880 | const ssmlTags = []; 881 | let tempText = text.replace(//g, (match) => { 882 | ssmlTags.push(match); 883 | return `__SSML_TAG_${ssmlTags.length - 1}__`; 884 | }); 885 | 886 | // 转义其他特殊字符 887 | tempText = tempText 888 | .replace(/&/g, '&') 889 | .replace(//g, '>') 891 | .replace(/"/g, '"') 892 | .replace(/'/g, '''); 893 | 894 | // 还原 SSML 标签 895 | tempText = tempText.replace(/__SSML_TAG_(\d+)__/g, (_, index) => ssmlTags[parseInt(index)]); 896 | 897 | return tempText; 898 | } 899 | 900 | async function makeRequest(url, isPreview, text, requestInfo = '', speakerId = null) { 901 | try { 902 | // 获取当前API类型 903 | const apiName = $('#api').val(); 904 | const customApi = customAPIs[apiName]; 905 | const isCustomApi = !!customApi; 906 | const apiFormat = customApi ? (customApi.format || 'openai') : (apiName === 'oai-tts' ? 'openai' : 'edge'); 907 | 908 | // 如果是OAI-TTS或自定义OpenAI格式API,移除所有的停顿标签 909 | if (apiFormat === 'openai') { 910 | text = text.replace(//g, ''); 911 | 912 | // 对OAI格式API添加文本长度验证 - 修改长度限制逻辑 913 | const textLength = getTextLength(text); 914 | const maxSegmentLength = customApi?.maxLength || 1000; 915 | const maxTotalLength = customApi?.maxLength ? customApi.maxLength * 5 : 5000; 916 | 917 | // 检查总长度限制 918 | if (textLength > maxTotalLength) { 919 | throw new Error(`OpenAI格式API文本总长度超限,最多支持${maxTotalLength}个单位,当前长度: ${textLength}`); 920 | } 921 | 922 | // 检查单段长度限制(用于非预览请求) 923 | if (!isPreview && textLength > maxSegmentLength) { 924 | // 这里不抛错,让分段逻辑处理 925 | console.log(`文本将被分段处理,单段限制: ${maxSegmentLength}个单位`); 926 | } 927 | } else { 928 | // 转义文本中的特殊字符,但保护 SSML 标签 929 | text = escapeXml(text); 930 | } 931 | 932 | const headers = { 933 | 'Accept': 'audio/mpeg', 934 | 'Content-Type': 'application/json' 935 | }; 936 | 937 | // 使用传入的speakerId(如果有)或者当前选择的speakerId 938 | const voice = speakerId || $('#speaker').val(); 939 | 940 | let requestBody; 941 | let requestUrl = url; 942 | 943 | // 根据不同的API创建不同的请求体 944 | if (apiFormat === 'openai') { 945 | const instructions = $('#instructions').val().trim(); 946 | const format = $('#audioFormat').val(); 947 | 948 | requestBody = { 949 | model: voice, // 对于OpenAI格式API,voice是model 950 | input: text, 951 | voice: isCustomApi ? "alloy" : voice, // 自定义API使用模型ID作为model参数,voice参数设置为默认值 952 | response_format: format 953 | }; 954 | 955 | // 只有当instructions不为空时才添加到请求体中 956 | if (instructions) { 957 | requestBody.instructions = instructions; 958 | } 959 | 960 | // 如果是自定义API且有apiKey,添加Authorization头 961 | if (isCustomApi && customApi.apiKey) { 962 | headers['Authorization'] = `Bearer ${customApi.apiKey}`; 963 | } 964 | } else { 965 | requestBody = { 966 | text: text, 967 | voice: voice, 968 | rate: parseInt($('#rate').val()), 969 | pitch: parseInt($('#pitch').val()), 970 | preview: isPreview 971 | }; 972 | 973 | // 如果是自定义Edge格式API且有apiKey 974 | if (isCustomApi && customApi.apiKey) { 975 | // 检查是否是x-api-key格式 976 | if (customApi.apiKey.toLowerCase().startsWith('x-api-key:')) { 977 | const keyValue = customApi.apiKey.substring('x-api-key:'.length).trim(); 978 | headers['x-api-key'] = keyValue; 979 | } else { 980 | headers['Authorization'] = `Bearer ${customApi.apiKey}`; 981 | } 982 | } 983 | } 984 | 985 | console.log('发送请求到:', requestUrl); 986 | 987 | const response = await fetch(requestUrl, { 988 | method: 'POST', 989 | headers: headers, 990 | body: JSON.stringify(requestBody) 991 | }); 992 | 993 | console.log('Fetch 已完成加载:' + response.status); 994 | 995 | if (!response.ok) { 996 | // 增强错误信息,尝试获取响应内容 997 | const errorText = await response.text().catch(() => ''); 998 | console.error('服务器响应错误:', response.status, response.statusText, errorText); 999 | throw new Error(`服务器响应错误: ${response.status} - ${errorText || response.statusText}`); 1000 | } 1001 | 1002 | const blob = await response.blob(); 1003 | 1004 | // 验证返回的blob是否为有效的音频文件 1005 | if (!blob.type.includes('audio/') || blob.size === 0) { 1006 | throw new Error('无效的音频文件'); 1007 | } 1008 | 1009 | if (!isPreview) { 1010 | currentAudioURL = URL.createObjectURL(blob); 1011 | $('#result').show(); 1012 | $('#audio').attr('src', currentAudioURL); 1013 | $('#download') 1014 | .removeClass('disabled') 1015 | .attr('href', currentAudioURL); 1016 | 1017 | // 设置下载文件名 1018 | const audioFormat = (apiFormat === 'openai') ? $('#audioFormat').val() : 'mp3'; 1019 | $('#download').attr('download', `voice.${audioFormat}`); 1020 | } 1021 | 1022 | return blob; 1023 | } catch (error) { 1024 | console.error('请求错误:', error); 1025 | showError(error.message); 1026 | throw error; 1027 | } 1028 | } 1029 | 1030 | function showError(message) { 1031 | showMessage(message, 'danger'); 1032 | } 1033 | 1034 | function addHistoryItem(timestamp, speaker, text, audioBlob, requestInfo = '') { 1035 | const MAX_HISTORY = 50; 1036 | const historyItems = $('#historyItems'); 1037 | 1038 | if (historyItems.children().length >= MAX_HISTORY) { 1039 | const oldestItem = historyItems.children().last(); 1040 | oldestItem.remove(); 1041 | } 1042 | 1043 | const audioURL = URL.createObjectURL(audioBlob); 1044 | cachedAudio.set(audioURL, audioBlob); 1045 | 1046 | // 清理文本中的 SSML 标签 1047 | const cleanText = text.replace(//g, ''); 1048 | 1049 | const historyItem = $(` 1050 |
1051 |
1052 | 1053 | ${requestInfo} 1054 | ${timestamp} - ${speaker} - ${cleanText} 1055 | 1056 |
1057 | 1060 | 1063 |
1064 |
1065 |
1066 | `); 1067 | 1068 | // 添加整个条目的点击事件 1069 | historyItem.on('click', function(e) { 1070 | // 如果点击的是按钮,不触发条目的点击事件 1071 | if (!$(e.target).closest('.btn-group').length) { 1072 | playAudio(audioURL); 1073 | // 更新预览区 1074 | if (currentAudioURL) { 1075 | URL.revokeObjectURL(currentAudioURL); 1076 | } 1077 | currentAudioURL = URL.createObjectURL(cachedAudio.get(audioURL)); 1078 | $('#result').show(); 1079 | $('#audio').attr('src', currentAudioURL); 1080 | $('#download') 1081 | .removeClass('disabled') 1082 | .attr('href', currentAudioURL); 1083 | } 1084 | }); 1085 | 1086 | // 在条目被移除时清理资源 1087 | historyItem.on('remove', () => { 1088 | URL.revokeObjectURL(audioURL); 1089 | cachedAudio.delete(audioURL); 1090 | }); 1091 | 1092 | historyItem.find('.play-btn').on('click', function(e) { 1093 | e.stopPropagation(); // 阻止事件冒泡 1094 | playAudio($(this).data('url')); 1095 | }); 1096 | 1097 | $('#historyItems').prepend(historyItem); 1098 | setTimeout(() => historyItem.animate({ opacity: 1 }, 300), 50); 1099 | } 1100 | 1101 | function playAudio(audioURL) { 1102 | const audioElement = $('#audio')[0]; 1103 | const allPlayButtons = $('.play-btn'); 1104 | 1105 | // 如果点击的是当前正在播放的音频 1106 | if (audioElement.src === audioURL && !audioElement.paused) { 1107 | audioElement.pause(); 1108 | allPlayButtons.each(function() { 1109 | if ($(this).data('url') === audioURL) { 1110 | $(this).html(''); 1111 | } 1112 | }); 1113 | return; 1114 | } 1115 | 1116 | // 重置所有按钮标 1117 | allPlayButtons.html(''); 1118 | 1119 | // 设置新的音频源并播放 1120 | audioElement.src = audioURL; 1121 | audioElement.load(); 1122 | 1123 | // 只在实际播放时才设置错误处理 1124 | audioElement.play().then(() => { 1125 | // 更新当前播放按钮图标 1126 | allPlayButtons.each(function() { 1127 | if ($(this).data('url') === audioURL) { 1128 | $(this).html(''); 1129 | } 1130 | }); 1131 | }).catch(error => { 1132 | if (error.name !== 'AbortError') { // 忽略中止错误 1133 | console.error('播放失败:', error); 1134 | showError('音频播放失败,请重试'); 1135 | } 1136 | }); 1137 | 1138 | // 监听播放结束事件 1139 | audioElement.onended = function() { 1140 | allPlayButtons.each(function() { 1141 | if ($(this).data('url') === audioURL) { 1142 | $(this).html(''); 1143 | } 1144 | }); 1145 | }; 1146 | } 1147 | 1148 | function downloadAudio(audioURL) { 1149 | const blob = cachedAudio.get(audioURL); 1150 | if (blob) { 1151 | const link = document.createElement('a'); 1152 | link.href = URL.createObjectURL(blob); 1153 | link.download = 'audio.mp3'; 1154 | document.body.appendChild(link); 1155 | link.click(); 1156 | document.body.removeChild(link); 1157 | URL.revokeObjectURL(link.href); 1158 | } 1159 | } 1160 | 1161 | function clearHistory() { 1162 | $('#historyItems .history-item').each(function() { 1163 | $(this).remove(); 1164 | }); 1165 | 1166 | // 清理所有缓存的音频 1167 | cachedAudio.forEach((blob, url) => { 1168 | URL.revokeObjectURL(url); 1169 | }); 1170 | cachedAudio.clear(); 1171 | 1172 | $('#historyItems').empty(); 1173 | alert("历史记录已清除!"); 1174 | } 1175 | 1176 | function initializeAudioPlayer() { 1177 | const audio = document.getElementById('audio'); 1178 | audio.style.borderRadius = '12px'; 1179 | audio.style.width = '100%'; 1180 | audio.style.marginTop = '20px'; 1181 | 1182 | // 初始状态设置 1183 | $('#download') 1184 | .addClass('disabled') 1185 | .attr('href', '#'); 1186 | $('#audio').attr('src', ''); 1187 | } 1188 | 1189 | function showMessage(message, type = 'danger') { 1190 | const toast = $(` 1191 |
1192 |
1193 | ${message} 1194 |
1195 |
1196 | `); 1197 | 1198 | $('.toast-container').append(toast); 1199 | 1200 | // 显示动画 1201 | setTimeout(() => { 1202 | toast.addClass('show'); 1203 | }, 100); 1204 | 1205 | // 3秒后淡出并移除 1206 | setTimeout(() => { 1207 | toast.removeClass('show'); 1208 | setTimeout(() => toast.remove(), 300); 1209 | }, 3000); 1210 | } 1211 | 1212 | // 添加句子结束符号的正则表达式 1213 | const SENTENCE_ENDINGS = /[.。!!??]/; 1214 | const PARAGRAPH_ENDINGS = /[\n\r]/; 1215 | 1216 | function getTextLength(str) { 1217 | // 移除 XML 标签,但记录停顿时间 1218 | let totalPauseTime = 0; 1219 | const textWithoutTags = str.replace(//g, (match, time, unit) => { 1220 | const seconds = unit === 'ms' ? parseFloat(time) / 1000 : parseFloat(time); 1221 | totalPauseTime += seconds; 1222 | return ''; 1223 | }); 1224 | 1225 | // 计算文本长度(中文2字符,英文1字符) 1226 | const textLength = textWithoutTags.split('').reduce((acc, char) => { 1227 | return acc + (char.charCodeAt(0) > 127 ? 2 : 1); 1228 | }, 0); 1229 | 1230 | // 将停顿时间转换为等效字符长度(1秒 = 11个单位,相当于5.5个中文字符) 1231 | const pauseLength = Math.round(totalPauseTime * 11); 1232 | 1233 | return textLength + pauseLength; 1234 | } 1235 | 1236 | // 返回每个API的最大段长和总长 1237 | function getApiLimits(apiName) { 1238 | // returns { maxSegment, maxTotal } for each API 1239 | if (apiName === 'oai-tts' || (customAPIs[apiName] && customAPIs[apiName].format === 'openai')) { 1240 | return { maxSegment: 400, maxTotal: 2000 }; 1241 | } else { 1242 | return { maxSegment: 5000, maxTotal: 100000 }; 1243 | } 1244 | } 1245 | 1246 | function splitText(text) { 1247 | const apiName = $('#api').val(); 1248 | const { maxSegment } = getApiLimits(apiName); 1249 | const segments = []; 1250 | let remainingText = text.trim(); 1251 | 1252 | const punctuationGroups = [ 1253 | // 第一优先级: 换行符 1254 | ['\n', '\r\n'], 1255 | 1256 | // 第二优先级: 句末标点 1257 | [ 1258 | '。', '!', '?', // 中文 1259 | '.', '!', '?', // 英文 1260 | '。', '!', '?', // 日文 1261 | '︒', '︕', '︖', // 全角 1262 | '。', '!', '?', // 半角/阿拉伯文 1263 | '。', '॥', // 梵文 1264 | '؟', '۔', // 阿拉伯文 1265 | '។', '៕', // 高棉文 1266 | '။', '၏', // 缅甸文 1267 | '¿', '¡', // 西班牙文 1268 | '‼', '⁇', '⁈', '⁉', // 组合标点 1269 | '‽','~' // 叹问号 1270 | ], 1271 | 1272 | // 第三优先级: 分号 1273 | [ 1274 | ';', ';', // 中英文 1275 | ';', // 日文 1276 | '︔', '︐', // 全角 1277 | '؛', // 阿拉伯文 1278 | '፤', // 埃塞俄比亚文 1279 | '꛶' // 巴姆穆文 1280 | ], 1281 | 1282 | // 第四优先级: 逗号和冒号 1283 | [ 1284 | ',', ':', // 中文 1285 | ',', ':', // 英文 1286 | '、', ',', ':', // 日文 1287 | '︑', '︓', // 全角 1288 | '、', ':', '、', // 半角/阿拉伯文 1289 | '፣', '፥', // 埃塞俄比亚文 1290 | '၊', '၌', // 缅甸文 1291 | '、', '؍', // 波斯文 1292 | '׀', ',' // 希伯来文 1293 | ], 1294 | 1295 | // 第五优先级: 其他标点 1296 | [ 1297 | '、', '…', '―', '─', // 中文破折号 1298 | '-', '—', '–', // 英文破折号 1299 | '‥', '〳', '〴', '〵', // 日文重复符号 1300 | '᠁', '᠂', '᠃', // 蒙古文 1301 | '᭛', '᭜', '᭝' // 巴厘文 1302 | ], 1303 | 1304 | // 第六优先级: 空格和其他分隔符 1305 | [ 1306 | ' ', '\t', // 空格和制表符 1307 | ' ', // 全角空格 1308 | '〿', '〮', '〯', // 其他分隔符 1309 | '᠀', // 蒙古文分隔符 1310 | '᭟', '᭠', // 巴厘文分隔符 1311 | '᳓', '᳔', '᳕' // 韵律标记 1312 | ] 1313 | ]; 1314 | 1315 | while (remainingText.length > 0) { 1316 | let splitIndex = remainingText.length; 1317 | let currentLength = 0; 1318 | let bestSplitIndex = -1; 1319 | let bestPriorityFound = -1; 1320 | 1321 | for (let i = 0; i < remainingText.length; i++) { 1322 | currentLength += remainingText.charCodeAt(i) > 127 ? 2 : 1; 1323 | 1324 | if (currentLength > maxSegment) { 1325 | splitIndex = i; 1326 | // 先遍历优先级组 1327 | for (let priority = 0; priority < punctuationGroups.length; priority++) { 1328 | let searchLength = 0; 1329 | // 在300单位范围内搜索当前优先级的标点 1330 | for (let j = i; j >= 0 && searchLength <= 300; j--) { 1331 | searchLength += remainingText.charCodeAt(j) > 127 ? 2 : 1; 1332 | 1333 | if (punctuationGroups[priority].includes(remainingText[j])) { 1334 | // 找到当前优先级的分段点,记录位置并停止搜索 1335 | bestPriorityFound = priority; 1336 | bestSplitIndex = j; 1337 | break; 1338 | } 1339 | } 1340 | // 如果在当前优先级找到了分段点,就不再检查更低优先级 1341 | if (bestSplitIndex > -1) break; 1342 | } 1343 | break; 1344 | } 1345 | } 1346 | 1347 | if (bestSplitIndex > 0) { 1348 | splitIndex = bestSplitIndex + 1; 1349 | } 1350 | 1351 | segments.push(remainingText.substring(0, splitIndex)); 1352 | remainingText = remainingText.substring(splitIndex).trim(); 1353 | } 1354 | 1355 | return segments; 1356 | } 1357 | 1358 | function showLoading(message) { 1359 | let loadingToast = $('.toast-loading'); 1360 | if (loadingToast.length) { 1361 | // 如果已存在 loading toast,只更新进度条,不更新消息 1362 | loadingToast.find('.progress-bar').css('width', '0%'); 1363 | return; 1364 | } 1365 | 1366 | // 创建新的loading提示 1367 | const toast = $(` 1368 |
1369 |
1370 |
1371 | 1372 |
${message}
1373 |
1374 |
1375 |
1376 |
1377 |
1378 |
1379 | `); 1380 | 1381 | $('.toast-container').append(toast); 1382 | setTimeout(() => toast.addClass('show'), 100); 1383 | } 1384 | 1385 | function hideLoading() { 1386 | const loadingToast = $('.toast-loading'); 1387 | loadingToast.removeClass('show'); 1388 | setTimeout(() => loadingToast.remove(), 300); 1389 | } 1390 | 1391 | function updateLoadingProgress(progress, message) { 1392 | const loadingToast = $('.toast-loading'); 1393 | if (loadingToast.length) { 1394 | loadingToast.find('.progress-bar').css('width', `${progress}%`); 1395 | loadingToast.find('.loading-message').text(message); 1396 | } 1397 | } 1398 | 1399 | async function generateVoiceForLongText(segments, currentRequestId, currentSpeakerText, currentSpeakerId, apiUrl, apiName) { 1400 | const results = []; 1401 | const totalSegments = segments.length; 1402 | 1403 | // 获取原始文本并清理 SSML 标签 1404 | const originalText = $('#text').val(); 1405 | const cleanText = originalText.replace(//g, ''); 1406 | const shortenedText = cleanText.length > 7 ? cleanText.substring(0, 7) + '...' : cleanText; 1407 | 1408 | showLoading(''); 1409 | 1410 | let hasSuccessfulSegment = false; 1411 | const MAX_RETRIES = 3; 1412 | 1413 | for (let i = 0; i < segments.length; i++) { 1414 | let retryCount = 0; 1415 | let success = false; 1416 | let lastError = null; 1417 | 1418 | while (retryCount < MAX_RETRIES && !success) { 1419 | try { 1420 | const progress = ((i + 1) / totalSegments * 100).toFixed(1); 1421 | const retryInfo = retryCount > 0 ? `(重试 ${retryCount}/${MAX_RETRIES})` : ''; 1422 | updateLoadingProgress( 1423 | progress, 1424 | `正在生成#${currentRequestId}请求的 ${i + 1}/${totalSegments} 段语音${retryInfo}...` 1425 | ); 1426 | 1427 | // 为OAI-TTS API使用相同的instructions 1428 | let instructions = null; 1429 | if (apiName === 'oai-tts') { 1430 | instructions = $('#instructions').val().trim(); 1431 | } 1432 | 1433 | const requestInfo = `#${currentRequestId}(${i + 1}/${totalSegments})`; 1434 | 1435 | const blob = await makeRequest( 1436 | apiUrl, 1437 | false, 1438 | segments[i], 1439 | requestInfo, // 传递requestInfo而不是把它用作voice参数 1440 | currentSpeakerId // 确保这是正确的speaker ID 1441 | ); 1442 | 1443 | if (blob) { 1444 | hasSuccessfulSegment = true; 1445 | success = true; 1446 | results.push(blob); 1447 | const timestamp = new Date().toLocaleTimeString(); 1448 | // 使用传入的讲述人名称,而不是重新获取 1449 | const cleanSegmentText = segments[i].replace(//g, ''); 1450 | const shortenedSegmentText = cleanSegmentText.length > 7 ? cleanSegmentText.substring(0, 7) + '...' : cleanSegmentText; 1451 | const requestInfo = `#${currentRequestId}(${i + 1}/${totalSegments})`; 1452 | addHistoryItem(timestamp, currentSpeakerText, shortenedSegmentText, blob, requestInfo); 1453 | } 1454 | } catch (error) { 1455 | lastError = error; 1456 | retryCount++; 1457 | 1458 | if (retryCount < MAX_RETRIES) { 1459 | console.error(`分段 ${i + 1} 生成失败 (重试 ${retryCount}/${MAX_RETRIES}):`, error); 1460 | const waitTime = 3000 + (retryCount * 2000); 1461 | await new Promise(resolve => setTimeout(resolve, waitTime)); 1462 | } else { 1463 | showError(`第 ${i + 1}/${totalSegments} 段生成失败:${error.message}`); 1464 | } 1465 | } 1466 | } 1467 | 1468 | if (!success) { 1469 | console.error(`分段 ${i + 1} 在 ${MAX_RETRIES} 次尝试后仍然失败:`, lastError); 1470 | } 1471 | 1472 | if (success && i < segments.length - 1) { 1473 | await new Promise(resolve => setTimeout(resolve, 3000)); 1474 | } 1475 | } 1476 | 1477 | hideLoading(); 1478 | 1479 | if (results.length > 0) { 1480 | const finalBlob = new Blob(results, { type: 'audio/mpeg' }); 1481 | const timestamp = new Date().toLocaleTimeString(); 1482 | // 使用传入的讲述人名称,而不是重新获取 1483 | const mergeRequestInfo = `#${currentRequestId}(合并)`; 1484 | addHistoryItem(timestamp, currentSpeakerText, shortenedText, finalBlob, mergeRequestInfo); 1485 | return finalBlob; 1486 | } 1487 | 1488 | throw new Error('所有片段生成失败'); 1489 | } 1490 | 1491 | // 在 body 末尾添加 toast 容器 1492 | $('body').append('
'); 1493 | 1494 | // 可以添加其他类型的消息提示 1495 | function showWarning(message) { 1496 | showMessage(message, 'warning'); 1497 | } 1498 | 1499 | function showInfo(message) { 1500 | showMessage(message, 'info'); 1501 | } 1502 | 1503 | // 可以添加其他类型的消息提示 1504 | function showWarning(message) { 1505 | showMessage(message, 'warning'); 1506 | } 1507 | 1508 | function showInfo(message) { 1509 | showMessage(message, 'info'); 1510 | } 1511 | 1512 | // 根据选择的API格式更新表单占位符 1513 | function updateApiFormPlaceholders(format) { 1514 | if (format === 'openai') { 1515 | $('#apiEndpoint').attr('placeholder', 'https://api.openai.com/v1/audio/speech'); 1516 | $('#modelEndpoint').attr('placeholder', 'https://api.openai.com/v1/models'); 1517 | $('#apiKey').attr('placeholder', 'sk-...'); 1518 | $('#manualSpeakers').attr('placeholder', 'tts-1,tts-1-hd,alloy,echo,fable,onyx,nova,shimmer'); 1519 | } else if (format === 'edge') { 1520 | $('#apiEndpoint').attr('placeholder', 'https://api.example.com/api/tts'); 1521 | $('#modelEndpoint').attr('placeholder', 'https://api.example.com/api/voices'); 1522 | $('#apiKey').attr('placeholder', 'x-api-key: ...'); 1523 | $('#manualSpeakers').attr('placeholder', 'zh-CN-XiaoxiaoNeural,en-US-AriaNeural,...'); 1524 | } 1525 | 1526 | // Set default values only when creating a new API (editingApiId is null) 1527 | if (editingApiId === null) { 1528 | if (format === 'openai') { 1529 | $('#apiEndpoint').val('https://api.openai.com/v1/audio/speech'); 1530 | $('#modelEndpoint').val('https://api.openai.com/v1/models'); 1531 | } else { 1532 | // For Edge or other formats, clear these fields or set to a generic example if desired 1533 | // For now, clearing them is consistent with form.reset() behavior for other fields 1534 | $('#apiEndpoint').val(''); 1535 | $('#modelEndpoint').val(''); 1536 | } 1537 | // Other fields like apiName, apiKey, manualSpeakers, maxLength are reset by form.reset() 1538 | // or explicitly cleared when the modal is opened for a new API. 1539 | } 1540 | } 1541 | 1542 | // 添加删除自定义API的函数 1543 | function deleteCustomApi(apiId) { 1544 | if (!customAPIs[apiId]) { 1545 | showError('找不到要删除的API'); 1546 | return; 1547 | } 1548 | 1549 | const apiName = customAPIs[apiId].name; 1550 | 1551 | if (confirm(`确定要删除自定义API「${apiName}」吗?`)) { 1552 | // 删除自定义API 1553 | delete customAPIs[apiId]; 1554 | delete API_CONFIG[apiId]; 1555 | 1556 | // 保存到localStorage 1557 | localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); 1558 | 1559 | // 更新API选项 1560 | updateApiOptions(); 1561 | 1562 | // 如果当前选中的是被删除的API,切换到edge-api 1563 | if ($('#api').val() === apiId) { 1564 | $('#api').val('edge-api').trigger('change'); 1565 | } 1566 | 1567 | // 刷新API列表 1568 | refreshSavedApisList(); 1569 | 1570 | showInfo(`已删除API: ${apiName}`); 1571 | } 1572 | } -------------------------------------------------------------------------------- /speakers.json: -------------------------------------------------------------------------------- 1 | { 2 | "edge-api": { 3 | "speakers": { 4 | "zh-CN-XiaoxiaoNeural": "晓晓", 5 | "zh-CN-YunxiNeural": "云希", 6 | "zh-CN-YunjianNeural": "云健", 7 | "zh-CN-XiaoyiNeural": "晓伊", 8 | "zh-CN-YunyangNeural": "云扬", 9 | "zh-CN-XiaochenNeural": "晓辰", 10 | "zh-CN-XiaochenMultilingualNeural": "晓辰 多语言", 11 | "zh-CN-XiaohanNeural": "晓涵", 12 | "zh-CN-XiaomengNeural": "晓梦", 13 | "zh-CN-XiaomoNeural": "晓墨", 14 | "zh-CN-XiaoqiuNeural": "晓秋", 15 | "zh-CN-XiaorouNeural": "晓柔", 16 | "zh-CN-XiaoruiNeural": "晓睿", 17 | "zh-CN-XiaoshuangNeural": "晓双", 18 | "zh-CN-XiaoxiaoDialectsNeural": "晓晓 方言", 19 | "zh-CN-XiaoxiaoMultilingualNeural": "晓晓 多语言", 20 | "zh-CN-XiaoyanNeural": "晓颜", 21 | "zh-CN-XiaoyouNeural": "晓悠", 22 | "zh-CN-XiaoyuMultilingualNeural": "晓羽 多语言", 23 | "zh-CN-XiaozhenNeural": "晓甄", 24 | "zh-CN-YunfengNeural": "云枫", 25 | "zh-CN-YunhaoNeural": "云皓", 26 | "zh-CN-YunjieNeural": "云杰", 27 | "zh-CN-YunxiaNeural": "云夏", 28 | "zh-CN-YunyeNeural": "云野", 29 | "zh-CN-YunyiMultilingualNeural": "云逸 多语言", 30 | "zh-CN-YunzeNeural": "云泽", 31 | "zh-CN-YunfanMultilingualNeural": "云帆 多语言", 32 | "zh-CN-YunxiaoMultilingualNeural": "云萧 多语言", 33 | "zh-CN-guangxi-YunqiNeural": "云奇 广西", 34 | "zh-CN-henan-YundengNeural": "云登 河南", 35 | "zh-CN-liaoning-XiaobeiNeural": "晓北 辽宁", 36 | "zh-CN-liaoning-YunbiaoNeural": "云彪 辽宁", 37 | "zh-CN-shaanxi-XiaoniNeural": "晓妮 山西", 38 | "zh-CN-shandong-YunxiangNeural": "云翔 山东", 39 | "zh-CN-sichuan-YunxiNeural": "云希 四川", 40 | "zh-HK-HiuMaanNeural": "晓曼 粤语", 41 | "zh-HK-WanLungNeural": "云龙 粤语", 42 | "zh-HK-HiuGaaiNeural": "晓佳 粤语", 43 | "zh-TW-HsiaoChenNeural": "晓臻 台湾", 44 | "zh-TW-YunJheNeural": "云哲 台湾", 45 | "zh-TW-HsiaoYuNeural": "晓雨 台湾", 46 | "en-AU-NatashaNeural": "Natasha-英语(澳大利亚)", 47 | "en-AU-WilliamNeural": "William-英语(澳大利亚)", 48 | "en-CA-ClaraNeural": "Clara-英语(加拿大)", 49 | "en-CA-LiamNeural": "Liam-英语(加拿大)", 50 | "en-GB-SoniaNeural": "Sonia-英语(英国)", 51 | "en-GB-RyanNeural": "Ryan-英语(英国)", 52 | "en-HK-YanNeural": "Yan-英语(香港)", 53 | "en-HK-SamNeural": "Sam-英语(香港)", 54 | "en-IE-EmilyNeural": "Emily-英语(爱尔兰)", 55 | "en-IE-ConnorNeural": "Connor-英语(爱尔兰)", 56 | "en-IN-NeerjaNeural": "Neerja-英语(印度)", 57 | "en-IN-PrabhatNeural": "Prabhat-英语(印度)", 58 | "en-KE-AsiliaNeural": "Asilia-英语(肯尼亚)", 59 | "en-KE-ChilembaNeural": "Chilemba-英语(肯尼亚)", 60 | "en-NG-EzinneNeural": "Ezinne-英语(尼日利亚)", 61 | "en-NG-AbeoNeural": "Abeo-英语(尼日利亚)", 62 | "en-NZ-MollyNeural": "Molly-英语(新西兰)", 63 | "en-NZ-MitchellNeural": "Mitchell-英语(新西兰)", 64 | "en-PH-RosaNeural": "Rosa-英语(菲律宾)", 65 | "en-PH-JamesNeural": "James-英语(菲律宾)", 66 | "en-SG-LunaNeural": "Luna-英语(新加坡)", 67 | "en-SG-WayneNeural": "Wayne-英语(新加坡)", 68 | "en-TZ-ImaniNeural": "Imani-英语(坦桑尼亚)", 69 | "en-TZ-ElimuNeural": "Elimu-英语(坦桑尼亚)", 70 | "en-US-JennyNeural": "Jenny-英语(美国)", 71 | "en-US-GuyNeural": "Guy-英语(美国)", 72 | "en-ZA-LeahNeural": "Leah-英语(南非)", 73 | "en-ZA-LukeNeural": "Luke-英语(南非)", 74 | "af-ZA-AdriNeural": "Adri-南非荷兰语", 75 | "af-ZA-WillemNeural": "Willem-南非荷兰语", 76 | "am-ET-MekdesNeural": "መቅደስ-阿姆哈拉语", 77 | "am-ET-AmehaNeural": "አምሀ-阿姆哈拉语", 78 | "ar-AE-FatimaNeural": "فاطمة-阿拉伯语(UAE)", 79 | "ar-AE-HamdanNeural": "حمدان-阿拉伯语(UAE)", 80 | "ar-BH-LailaNeural": "ليلى-阿拉伯语(巴林)", 81 | "ar-BH-AliNeural": "علي-阿拉伯语(巴林)", 82 | "ar-DZ-AminaNeural": "أمينة-阿拉伯语(阿尔及利亚)", 83 | "ar-DZ-IsmaelNeural": "إسماعيل-阿拉伯语(阿尔及利亚)", 84 | "ar-EG-SalmaNeural": "سلمى-阿拉伯语(埃及)", 85 | "ar-EG-ShakirNeural": "شاكر-阿拉伯语(埃及)", 86 | "ar-IQ-RanaNeural": "رنا-阿拉伯语(伊拉克)", 87 | "ar-IQ-BasselNeural": "باسل-阿拉伯语(伊拉克)", 88 | "ar-JO-SanaNeural": "سناء-阿拉伯语(约旦)", 89 | "ar-JO-TaimNeural": "تيم-阿拉伯语(约旦)", 90 | "ar-KW-NouraNeural": "نورا-阿拉伯语(科威特)", 91 | "ar-KW-FahedNeural": "فهد-阿拉伯语(科威特)", 92 | "ar-LB-LaylaNeural": "ليلى-阿拉伯语(黎巴嫩)", 93 | "ar-LB-RamiNeural": "رامي-阿拉伯语(黎巴嫩)", 94 | "ar-LY-ImanNeural": "إيمان-阿拉伯语(利比亚)", 95 | "ar-LY-OmarNeural": "أحمد-阿拉伯语(利比亚)", 96 | "ar-MA-MounaNeural": "منى-阿拉伯语(摩洛哥)", 97 | "ar-MA-JamalNeural": "جمال-阿拉伯语(摩洛哥)", 98 | "ar-OM-AyshaNeural": "عائشة-阿拉伯语(阿曼)", 99 | "ar-OM-AbdullahNeural": "عبدالله-阿拉伯语(阿曼)", 100 | "ar-QA-AmalNeural": "أمل-阿拉伯语(卡塔尔)", 101 | "ar-QA-MoazNeural": "معاذ-阿拉伯语(卡塔尔)", 102 | "ar-SA-ZariyahNeural": "زارية-阿拉伯语(沙特阿拉伯)", 103 | "ar-SA-HamedNeural": "حامد-阿拉伯语(沙特阿拉伯)", 104 | "ar-SY-AmanyNeural": "أماني-阿拉伯语(叙利亚)", 105 | "ar-SY-LaithNeural": "ليث-阿拉伯语(叙利亚)", 106 | "ar-TN-ReemNeural": "ريم-阿拉伯语(突尼斯)", 107 | "ar-TN-HediNeural": "هادي-阿拉伯语(突尼斯)", 108 | "ar-YE-MaryamNeural": "مريم-阿拉伯语(也门)", 109 | "ar-YE-SalehNeural": "صالح-阿拉伯语(也门)", 110 | "as-IN-YashicaNeural": "যাশিকা-阿萨姆语", 111 | "as-IN-PriyomNeural": "প্ৰিয়ম-阿萨姆语", 112 | "az-AZ-BanuNeural": "Banu-阿塞拜疆语", 113 | "az-AZ-BabekNeural": "Babək-阿塞拜疆语", 114 | "bg-BG-KalinaNeural": "Калина-保加利亚语", 115 | "bg-BG-BorislavNeural": "Борислав-保加利亚语", 116 | "bn-BD-NabanitaNeural": "নবনীতা-孟加拉语(孟加拉)", 117 | "bn-BD-PradeepNeural": "প্রদ্বীপ-孟加拉语(孟加拉)", 118 | "bn-IN-TanishaaNeural": "তানিশা-孟加拉语(印度)", 119 | "bn-IN-BashkarNeural": "ভাস্কর-孟加拉语(印度)", 120 | "bs-BA-VesnaNeural": "Vesna-波斯尼亚语", 121 | "bs-BA-GoranNeural": "Goran-波斯尼亚语", 122 | "ca-ES-JoanaNeural": "Joana-加泰罗尼亚语", 123 | "ca-ES-EnricNeural": "Enric-加泰罗尼亚语", 124 | "ca-ES-AlbaNeural": "Alba-加泰罗尼亚语", 125 | "cs-CZ-VlastaNeural": "Vlasta-捷克语", 126 | "cs-CZ-AntoninNeural": "Antonín-捷克语", 127 | "cy-GB-NiaNeural": "Nia-威尔士语", 128 | "cy-GB-AledNeural": "Aled-威尔士语", 129 | "da-DK-ChristelNeural": "Christel-丹麦语", 130 | "da-DK-JeppeNeural": "Jeppe-丹麦语", 131 | "de-AT-IngridNeural": "Ingrid-德语(奥地利)", 132 | "de-AT-JonasNeural": "Jonas-德语(奥地利)", 133 | "de-CH-LeniNeural": "Leni-德语(瑞士)", 134 | "de-CH-JanNeural": "Jan-德语(瑞士)", 135 | "de-DE-KatjaNeural": "Katja-德语(德国)", 136 | "de-DE-ConradNeural": "Conrad-德语(德国)", 137 | "de-DE-AmalaNeural": "Amala-德语(德国)", 138 | "de-DE-BerndNeural": "Bernd-德语(德国)", 139 | "de-DE-ChristophNeural": "Christoph-德语(德国)", 140 | "de-DE-ElkeNeural": "Elke-德语(德国)", 141 | "de-DE-GiselaNeural": "Gisela-德语(德国)", 142 | "de-DE-KasperNeural": "Kasper-德语(德国)", 143 | "de-DE-KillianNeural": "Killian-德语(德国)", 144 | "de-DE-KlarissaNeural": "Klarissa-德语(德国)", 145 | "de-DE-KlausNeural": "Klaus-德语(德国)", 146 | "de-DE-LouisaNeural": "Louisa-德语(德国)", 147 | "de-DE-MajaNeural": "Maja-德语(德国)", 148 | "de-DE-RalfNeural": "Ralf-德语(德国)", 149 | "de-DE-TanjaNeural": "Tanja-德语(德国)", 150 | "el-GR-AthinaNeural": "Αθηνά-希腊语", 151 | "el-GR-NestorasNeural": "Νέστορας-希腊语", 152 | "es-AR-ElenaNeural": "Elena-西班牙语(阿根廷)", 153 | "es-AR-TomasNeural": "Tomas-西班牙语(阿根廷)", 154 | "es-BO-MarceloNeural": "Marcelo-西班牙语(玻利维亚)", 155 | "es-BO-SofiaNeural": "Sofia-西班牙语(玻利维亚)", 156 | "es-CL-CatalinaNeural": "Catalina-西班牙语(智利)", 157 | "es-CL-LorenzoNeural": "Lorenzo-西班牙语(智利)", 158 | "es-CO-GonzaloNeural": "Gonzalo-西班牙语(哥伦比亚)", 159 | "es-CO-SalomeNeural": "Salome-西班牙语(哥伦比亚)", 160 | "es-CR-JuanNeural": "Juan-西班牙语(哥斯达黎加)", 161 | "es-CR-MariaNeural": "Maria-西班牙语(哥斯达黎加)", 162 | "es-CU-BelkysNeural": "Belkys-西班牙语(古巴)", 163 | "es-CU-ManuelNeural": "Manuel-西班牙语(古巴)", 164 | "es-DO-EmilioNeural": "Emilio-西班牙语(多米尼加)", 165 | "es-DO-RamonaNeural": "Ramona-西班牙语(多米尼加)", 166 | "es-EC-AndreaNeural": "Andrea-西班牙语(厄瓜多尔)", 167 | "es-EC-LuisNeural": "Luis-西班牙语(厄瓜多尔)", 168 | "es-ES-ElviraNeural": "Elvira-西班牙语(西班牙)", 169 | "es-ES-AlvaroNeural": "Alvaro-西班牙语(西班牙)", 170 | "es-GQ-JavierNeural": "Javier-西班牙语(赤道几内亚)", 171 | "es-GQ-TeresaNeural": "Teresa-西班牙语(赤道几内亚)", 172 | "es-GT-AndresNeural": "Andres-西班牙语(危地马拉)", 173 | "es-GT-MartaNeural": "Marta-西班牙语(危地马拉)", 174 | "es-HN-CarlosNeural": "Carlos-西班牙语(洪都拉斯)", 175 | "es-HN-KarlaNeural": "Karla-西班牙语(洪都拉斯)", 176 | "es-MX-DaliaNeural": "Dalia-西班牙语(墨西哥)", 177 | "es-MX-JorgeNeural": "Jorge-西班牙语(墨西哥)", 178 | "es-NI-FedericoNeural": "Federico-西班牙语(尼加拉瓜)", 179 | "es-NI-YolandaNeural": "Yolanda-西班牙语(尼加拉瓜)", 180 | "es-PA-MargaritaNeural": "Margarita-西班牙语(巴拿马)", 181 | "es-PA-RobertoNeural": "Roberto-西班牙语(巴拿马)", 182 | "es-PE-AlexNeural": "Alex-西班牙语(秘鲁)", 183 | "es-PE-CamilaNeural": "Camila-西班牙语(秘鲁)", 184 | "es-PR-KarinaNeural": "Karina-西班牙语(波多黎各)", 185 | "es-PR-VictorNeural": "Victor-西班牙语(波多黎各)", 186 | "es-PY-MarioNeural": "Mario-西班牙语(巴拉圭)", 187 | "es-PY-TaniaNeural": "Tania-西班牙语(巴拉圭)", 188 | "es-SV-LorenaNeural": "Lorena-西班牙语(萨尔瓦多)", 189 | "es-SV-RodrigoNeural": "Rodrigo-西班牙语(萨尔瓦多)", 190 | "es-US-AlonsoNeural": "Alonso-西班牙语(美国)", 191 | "es-US-PalomaNeural": "Paloma-西班牙语(美国)", 192 | "es-UY-MateoNeural": "Mateo-西班牙语(乌拉圭)", 193 | "es-UY-ValentinaNeural": "Valentina-西班牙语(乌拉圭)", 194 | "es-VE-PaolaNeural": "Paola-西班牙语(委内瑞拉)", 195 | "es-VE-SebastianNeural": "Sebastian-西班牙语(委内瑞拉)", 196 | "et-EE-AnuNeural": "Anu-爱沙尼亚语", 197 | "et-EE-KertNeural": "Kert-爱沙尼亚语", 198 | "fa-IR-DilaraNeural": "Dilara-波斯语", 199 | "fa-IR-FaridNeural": "Farid-波斯语", 200 | "fi-FI-SelmaNeural": "Selma-芬兰语", 201 | "fi-FI-HarriNeural": "Harri-芬兰语", 202 | "fil-PH-BlessicaNeural": "Blessica-菲律宾语", 203 | "fil-PH-AngeloNeural": "Angelo-菲律宾语", 204 | "fr-BE-CharlineNeural": "Charline-法语(比利时)", 205 | "fr-BE-GerardNeural": "Gerard-法语(比利时)", 206 | "fr-CA-SylvieNeural": "Sylvie-法语(加拿大)", 207 | "fr-CA-JeanNeural": "Jean-法语(加拿大)", 208 | "fr-CH-ArianeNeural": "Ariane-法语(瑞士)", 209 | "fr-CH-FabriceNeural": "Fabrice-法语(瑞士)", 210 | "fr-FR-DeniseNeural": "Denise-法语(法国)", 211 | "fr-FR-HenriNeural": "Henri-法语(法国)", 212 | "ga-IE-OrlaNeural": "Orla-爱尔兰语", 213 | "ga-IE-ColmNeural": "Colm-爱尔兰语", 214 | "gl-ES-SabelaNeural": "Sabela-加利西亚语", 215 | "gl-ES-RoiNeural": "Roi-加利西亚语", 216 | "gu-IN-DhwaniNeural": "Dhwani-古吉拉特语", 217 | "gu-IN-NiranjanNeural": "Niranjan-古吉拉特语", 218 | "he-IL-HilaNeural": "Hila-希伯来语", 219 | "he-IL-AvriNeural": "Avri-希伯来语", 220 | "hi-IN-SwaraNeural": "Swara-印地语", 221 | "hi-IN-MadhurNeural": "Madhur-印地语", 222 | "hr-HR-GabrijelaNeural": "Gabrijela-克罗地亚语", 223 | "hr-HR-SreckoNeural": "Srećko-克罗地亚语", 224 | "hu-HU-NoemiNeural": "Noémi-匈牙利语", 225 | "hu-HU-TamasNeural": "Tamás-匈牙利语", 226 | "hy-AM-AnahitNeural": "Անահիտ-亚美尼亚语", 227 | "hy-AM-HaykNeural": "Հայկ-亚美尼亚语", 228 | "id-ID-GadisNeural": "Gadis-印度尼西亚语", 229 | "id-ID-ArdiNeural": "Ardi-印度尼西亚语", 230 | "is-IS-GudrunNeural": "Guðrún-冰岛语", 231 | "is-IS-GunnarNeural": "Gunnar-冰岛语", 232 | "it-IT-ElsaNeural": "Elsa-意大利语", 233 | "it-IT-DiegoNeural": "Diego-意大利语", 234 | "ja-JP-NanamiNeural": "Nanami-日语", 235 | "ja-JP-KeitaNeural": "Keita-日语", 236 | "jv-ID-SitiNeural": "Siti-爪哇语", 237 | "jv-ID-DimasNeural": "Dimas-爪哇语", 238 | "ka-GE-EkaNeural": "ეკა-格鲁吉亚语", 239 | "ka-GE-GiorgiNeural": "გიორგი-格鲁吉亚语", 240 | "kk-KZ-AigulNeural": "Айгүл-哈萨克语", 241 | "kk-KZ-DauletNeural": "Дәулет-哈萨克语", 242 | "km-KH-SreymomNeural": "ស្រីមុំ-柬埔寨语", 243 | "km-KH-PisethNeural": "ពិសិដ្ឋ-柬埔寨语", 244 | "kn-IN-SapnaNeural": "ಸಪ್ನಾ-卡纳达语", 245 | "kn-IN-GaganNeural": "ಗಗನ್-卡纳达语", 246 | "ko-KR-SunHiNeural": "선히-朝鲜语", 247 | "ko-KR-InJoonNeural": "인준-朝鲜语", 248 | "lo-LA-KeomanyNeural": "ເກດມະນີ-老挝语", 249 | "lo-LA-ChanthavongNeural": "ຈັນທະວົງ-老挝语", 250 | "lt-LT-OnaNeural": "Ona-立陶宛语", 251 | "lt-LT-LeonasNeural": "Leonas-立陶宛语", 252 | "lv-LV-EveritaNeural": "Everita-拉脱维亚语", 253 | "lv-LV-NilsNeural": "Nils-拉脱维亚语", 254 | "mk-MK-MarijaNeural": "Марија-马其顿语", 255 | "mk-MK-AleksandarNeural": "Александар-马其顿语", 256 | "ml-IN-SobhanaNeural": "സോഭന-马拉雅拉姆语", 257 | "ml-IN-MidhunNeural": "മിഥുൻ-马拉雅拉姆语", 258 | "mn-MN-YesuiNeural": "Есүй-蒙古语", 259 | "mn-MN-BataaNeural": "Батаа-蒙古语", 260 | "mr-IN-AarohiNeural": "आरोही-马拉地语", 261 | "mr-IN-ManoharNeural": "मनोहर-马拉地语", 262 | "ms-MY-YasminNeural": "Yasmin-马来语", 263 | "ms-MY-OsmanNeural": "Osman-马来语", 264 | "mt-MT-GraceNeural": "Grace-马耳他语", 265 | "mt-MT-JosephNeural": "Joseph-马耳他语", 266 | "my-MM-NilarNeural": "နီလာ-缅甸语", 267 | "my-MM-ThihaNeural": "သီဟ-缅甸语", 268 | "nb-NO-PernilleNeural": "Pernille-书面挪威语", 269 | "nb-NO-FinnNeural": "Finn-书面挪威语", 270 | "ne-NP-HemkalaNeural": "हेमकला-尼泊尔语", 271 | "ne-NP-SagarNeural": "सागर-尼泊尔语", 272 | "nl-BE-DenaNeural": "Dena-荷兰语(比利时)", 273 | "nl-BE-ArnaudNeural": "Arnaud-荷兰语(比利时)", 274 | "nl-NL-FennaNeural": "Fenna-荷兰语(荷兰)", 275 | "nl-NL-MaartenNeural": "Maarten-荷兰语(荷兰)", 276 | "or-IN-SwaraNeural": "ସ୍ୱରା-奥里亚语", 277 | "or-IN-AparajitaNeural": "ଅପରାଜିତା-奥里亚语", 278 | "pa-IN-GagandeepNeural": "ਗਗਨਦੀਪ-旁遮普语", 279 | "pa-IN-AmanNeural": "ਅਮਨ-旁遮普语", 280 | "pl-PL-ZofiaNeural": "Zofia-波兰语", 281 | "pl-PL-MarekNeural": "Marek-波兰语", 282 | "ps-AF-LatifaNeural": "لطيفه-普什图语", 283 | "ps-AF-GulNawazNeural": "ګل نواز-普什图语", 284 | "pt-BR-FranciscaNeural": "Francisca-葡萄牙语(巴西)", 285 | "pt-BR-AntonioNeural": "Antonio-葡萄牙语(巴西)", 286 | "pt-PT-RaquelNeural": "Raquel-葡萄牙语(葡萄牙)", 287 | "pt-PT-DuarteNeural": "Duarte-葡萄牙语(葡萄牙)", 288 | "ro-RO-AlinaNeural": "Alina-罗马尼亚语", 289 | "ro-RO-EmilNeural": "Emil-罗马尼亚语", 290 | "ru-RU-SvetlanaNeural": "Светлана-俄语", 291 | "ru-RU-DmitryNeural": "Дмитрий-俄语", 292 | "si-LK-ThiliniNeural": "තිළිණි-僧伽罗语", 293 | "si-LK-SameeraNeural": "සමීර-僧伽罗语" 294 | } 295 | }, 296 | "oai-tts": { 297 | "speakers": { 298 | "alloy": "Alloy - 平衡中性", 299 | "echo": "Echo - 高级人工智能", 300 | "fable": "Fable - 英式语调", 301 | "onyx": "Onyx - 威严有力", 302 | "nova": "Nova - 温暖清晰", 303 | "shimmer": "Shimmer - 轻快乐观", 304 | "ash": "Ash - 中性平稳", 305 | "coral": "Coral - 温暖活力", 306 | "sage": "Sage - 睿智专业", 307 | "ballad": "Ballad - 情感丰富", 308 | "verse": "Verse - 饱满深沉" 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f0f4f8; 3 | } 4 | 5 | .card { 6 | border: none; 7 | border-radius: 15px; 8 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); 9 | transition: all 0.3s ease; 10 | background: linear-gradient(145deg, #ffffff 0%, #e6f0f8 100%); 11 | } 12 | 13 | .card:hover { 14 | transform: translateY(-2px); 15 | box-shadow: 0 6px 25px rgba(99, 102, 241, 0.08); 16 | } 17 | 18 | .card-header { 19 | background: linear-gradient(145deg, #4a90e2 0%, #6bb5ff 100%); 20 | border-bottom: none; 21 | border-radius: 15px 15px 0 0 !important; 22 | padding: 1.5rem 1rem; 23 | position: relative; 24 | overflow: hidden; 25 | } 26 | 27 | .card-header::before { 28 | content: ''; 29 | position: absolute; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | bottom: 0; 34 | background: linear-gradient(145deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%); 35 | pointer-events: none; 36 | } 37 | 38 | .card-header h2 { 39 | color: #ffffff; 40 | margin: 0; 41 | font-weight: 500; 42 | position: relative; 43 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); 44 | } 45 | 46 | .card-body { 47 | background: linear-gradient(165deg, #ffffff 0%, #e6f0f8 100%); 48 | border-radius: 0 0 15px 15px; 49 | padding: 1.5rem; 50 | } 51 | 52 | .history-container { 53 | max-height: 700px; 54 | overflow-y: auto; 55 | padding: 0.5rem; 56 | } 57 | 58 | .history-container::-webkit-scrollbar { 59 | width: 8px; 60 | } 61 | 62 | .history-container::-webkit-scrollbar-track { 63 | background: rgba(0, 0, 0, 0.02); 64 | border-radius: 4px; 65 | } 66 | 67 | .history-container::-webkit-scrollbar-thumb { 68 | background: rgba(99, 102, 241, 0.2); 69 | border-radius: 4px; 70 | } 71 | 72 | .history-container::-webkit-scrollbar-thumb:hover { 73 | background: rgba(99, 102, 241, 0.3); 74 | } 75 | 76 | .history-item { 77 | border-radius: 10px; 78 | margin-bottom: 12px; 79 | background: linear-gradient(145deg, #ffffff 0%, #f8faff 100%); 80 | border: 1px solid rgba(99, 102, 241, 0.08); 81 | transition: all 0.2s ease; 82 | padding: 1rem; 83 | cursor: pointer; 84 | } 85 | 86 | .history-item:hover { 87 | transform: translateY(-1px); 88 | box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08); 89 | border-color: rgba(99, 102, 241, 0.15); 90 | background: linear-gradient(145deg, #f8faff 0%, #f0f7ff 100%); 91 | } 92 | 93 | .btn-group .btn { 94 | margin-left: 5px; 95 | } 96 | 97 | footer { 98 | padding: 30px; 99 | color: #64748b; 100 | } 101 | 102 | footer a { 103 | color: #64748b; 104 | transition: color 0.2s ease; 105 | } 106 | 107 | footer a:hover { 108 | color: #334155; 109 | text-decoration: none; 110 | } 111 | 112 | #audio { 113 | border-radius: 12px; 114 | width: 100%; 115 | margin-top: 20px; 116 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); 117 | } 118 | 119 | .form-control { 120 | border-radius: 10px; 121 | border: 1px solid rgba(74, 144, 226, 0.15); 122 | transition: all 0.2s ease; 123 | background: #ffffff; 124 | -webkit-transition: all 0.2s ease; 125 | -moz-transition: all 0.2s ease; 126 | -ms-transition: all 0.2s ease; 127 | -o-transition: all 0.2s ease; 128 | } 129 | 130 | .form-control:focus { 131 | border-color: #6bb5ff; 132 | box-shadow: 0 0 0 0.2rem rgba(74, 144, 226, 0.12); 133 | background: #ffffff; 134 | } 135 | 136 | .btn { 137 | transition: all 0.2s ease; 138 | border-radius: 10px; 139 | font-weight: 500; 140 | } 141 | 142 | .btn-primary { 143 | background-color: #4a90e2; 144 | border-color: #4a90e2; 145 | } 146 | 147 | .btn-primary:hover { 148 | background-color: #357ab8; 149 | border-color: #357ab8; 150 | } 151 | 152 | .btn-info { 153 | background-color: #5bc0de; 154 | border-color: #5bc0de; 155 | } 156 | 157 | .btn-info:hover { 158 | background-color: #31b0d5; 159 | border-color: #31b0d5; 160 | } 161 | 162 | .btn-success { 163 | background-color: #5cb85c; 164 | border-color: #5cb85c; 165 | } 166 | 167 | .btn-success:hover { 168 | background-color: #4cae4c; 169 | border-color: #4cae4c; 170 | } 171 | 172 | .alert { 173 | border-radius: 10px; 174 | border: none; 175 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 176 | } 177 | 178 | #loading { 179 | color: #6366f1; 180 | } 181 | 182 | .fa-spinner { 183 | animation: spin 1s linear infinite; 184 | } 185 | 186 | @keyframes spin { 187 | from { transform: rotate(0deg); } 188 | to { transform: rotate(360deg); } 189 | } 190 | 191 | .btn-warning { 192 | background: linear-gradient(145deg, #fbbf24 0%, #f59e0b 100%); 193 | border: none; 194 | color: white; 195 | } 196 | 197 | .btn-warning:hover { 198 | background: linear-gradient(145deg, #f59e0b 0%, #d97706 100%); 199 | color: white; 200 | } 201 | 202 | /* 添加按钮点击反馈效果 */ 203 | .btn:active { 204 | transform: scale(0.98); 205 | } 206 | 207 | /* 优化表单控件悬停状态 */ 208 | .form-control:hover { 209 | border-color: rgba(74, 144, 226, 0.3); 210 | } 211 | 212 | /* 添加悬浮提示框样式 */ 213 | .toast-container { 214 | position: fixed; 215 | top: 1rem; 216 | right: 1rem; 217 | z-index: 1050; 218 | max-width: 300px; 219 | } 220 | 221 | .toast { 222 | min-width: 200px; 223 | background: rgba(255, 255, 255, 0.95); 224 | border-radius: 10px; 225 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 226 | margin-bottom: 0.5rem; 227 | transition: all 0.3s ease; 228 | opacity: 0; 229 | } 230 | 231 | .toast.show { 232 | opacity: 1; 233 | } 234 | 235 | .toast-body { 236 | padding: 1rem; 237 | color: #fff; 238 | border-radius: 10px; 239 | } 240 | 241 | .toast-danger { 242 | background: linear-gradient(145deg, #ef4444 0%, #dc2626 100%); 243 | color: #ffffff !important; 244 | } 245 | 246 | .toast-warning { 247 | background: linear-gradient(145deg, #f59e0b 0%, #d97706 100%); 248 | color: #ffffff !important; 249 | } 250 | 251 | .toast-info { 252 | background: linear-gradient(145deg, #3b82f6 0%, #2563eb 100%); 253 | color: #ffffff !important; 254 | } 255 | 256 | .toast-success { 257 | background-color: #d4edda; 258 | color: #ffffff !important; 259 | background: linear-gradient(145deg, #10b981 0%, #059669 100%); 260 | } 261 | 262 | .toast-loading { 263 | background-color: #fff; 264 | color: #ffffff !important; 265 | background: linear-gradient(145deg, #6366f1 0%, #4f46e5 100%); 266 | border: 1px solid #e5e7eb; 267 | } 268 | 269 | /* Loading overlay */ 270 | #loadingOverlay { 271 | position: fixed; 272 | top: 0; 273 | left: 0; 274 | width: 100%; 275 | height: 100%; 276 | background: rgba(255, 255, 255, 0.8); 277 | display: none; 278 | align-items: center; 279 | justify-content: center; 280 | z-index: 2000; 281 | } 282 | #loadingOverlay .spinner-border { 283 | width: 3rem; 284 | height: 3rem; 285 | color: #6366f1; 286 | } 287 | 288 | /* 自定义API管理相关样式 */ 289 | .toast-container { 290 | position: fixed; 291 | top: 20px; 292 | right: 20px; 293 | z-index: 9999; 294 | max-width: 350px; 295 | } 296 | 297 | .toast { 298 | opacity: 0; 299 | transition: opacity 0.3s ease; 300 | margin-bottom: 10px; 301 | border-radius: 4px; 302 | overflow: hidden; 303 | box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); 304 | } 305 | 306 | .toast.show { 307 | opacity: 1; 308 | } 309 | 310 | .toast-body { 311 | padding: 0.75rem 1.25rem; 312 | border-radius: 4px; 313 | font-size: 0.9rem; 314 | } 315 | 316 | .toast-danger { 317 | background-color: #f8d7da; 318 | color: #721c24; 319 | border: 1px solid #f5c6cb; 320 | } 321 | 322 | .toast-warning { 323 | background-color: #fff3cd; 324 | color: #856404; 325 | border: 1px solid #ffeeba; 326 | } 327 | 328 | .toast-info { 329 | background-color: #d1ecf1; 330 | color: #0c5460; 331 | border: 1px solid #bee5eb; 332 | } 333 | 334 | .toast-success { 335 | background-color: #d4edda; 336 | color: #155724; 337 | border: 1px solid #c3e6cb; 338 | } 339 | 340 | .toast-loading { 341 | background-color: #fff; 342 | color: #212529; 343 | border: 1px solid #dee2e6; 344 | } 345 | 346 | /* 改进历史记录项目样式 */ 347 | .history-item { 348 | transition: all 0.3s ease; 349 | cursor: pointer; 350 | } 351 | 352 | .history-item:hover { 353 | background-color: #f8f9fa; 354 | } 355 | 356 | /* 进度条样式 */ 357 | .progress { 358 | height: 8px; 359 | margin-top: 10px; 360 | } 361 | 362 | .progress-bar { 363 | transition: width 0.3s ease; 364 | background-color: #007bff; 365 | } 366 | 367 | /* API 管理模态框改进 */ 368 | #apiManagerModal .modal-dialog { 369 | max-width: 700px; 370 | } 371 | 372 | #savedApisList .list-group-item { 373 | transition: all 0.3s ease; 374 | border-left: 3px solid transparent; 375 | } 376 | 377 | #savedApisList .list-group-item:hover { 378 | border-left-color: #007bff; 379 | background-color: #f8f9fa; 380 | } 381 | 382 | .api-selection-tools { 383 | background-color: #f8f9fa; 384 | padding: 10px; 385 | border-radius: 4px; 386 | margin-bottom: 10px; 387 | } 388 | 389 | /* 改进表单元素样式 */ 390 | .form-control:focus { 391 | border-color: #80bdff; 392 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 393 | } 394 | 395 | /* 按钮悬停动画 */ 396 | .btn { 397 | transition: all 0.2s ease; 398 | } 399 | 400 | .btn:hover { 401 | transform: translateY(-1px); 402 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 403 | } 404 | 405 | /* 自定义滚动条 */ 406 | ::-webkit-scrollbar { 407 | width: 8px; 408 | height: 8px; 409 | } 410 | 411 | ::-webkit-scrollbar-track { 412 | background: #f1f1f1; 413 | border-radius: 4px; 414 | } 415 | 416 | ::-webkit-scrollbar-thumb { 417 | background: #ccc; 418 | border-radius: 4px; 419 | } 420 | 421 | ::-webkit-scrollbar-thumb:hover { 422 | background: #999; 423 | } 424 | 425 | /* 响应式改进 */ 426 | @media (max-width: 768px) { 427 | .toast-container { 428 | top: 10px; 429 | right: 10px; 430 | left: 10px; 431 | max-width: none; 432 | } 433 | 434 | #savedApisList .list-group-item { 435 | flex-direction: column; 436 | align-items: flex-start; 437 | } 438 | 439 | #savedApisList .list-group-item .btn-group { 440 | margin-top: 10px; 441 | align-self: flex-end; 442 | } 443 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "rewrites": [ 4 | { 5 | "source": "/api/tts", 6 | "destination": "/api/tts.js" 7 | }, 8 | { 9 | "source": "/api/voices", 10 | "destination": "/api/voices.js" 11 | } 12 | ], 13 | "headers": [ 14 | { 15 | "source": "/(.*)", 16 | "headers": [ 17 | { "key": "Access-Control-Allow-Origin", "value": "*" }, 18 | { "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" }, 19 | { "key": "Access-Control-Allow-Headers", "value": "Content-Type, x-auth-token" } 20 | ] 21 | } 22 | ] 23 | } 24 | --------------------------------------------------------------------------------