├── ms-api-key-setting.png ├── Gemini-api-key-setting.png ├── OpenAI-api-key-setting.png ├── README.md ├── Gemini TTS.html ├── Microsoft Azure TTS-V1.js ├── OpenAI TTS.html └── Microsoft Azure TTS-V2.js /ms-api-key-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mir-xiong/tts/HEAD/ms-api-key-setting.png -------------------------------------------------------------------------------- /Gemini-api-key-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mir-xiong/tts/HEAD/Gemini-api-key-setting.png -------------------------------------------------------------------------------- /OpenAI-api-key-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mir-xiong/tts/HEAD/OpenAI-api-key-setting.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 使用说明 2 | 3 | 4 | 5 | ## 部署 6 | 7 | 本项目支持CF部署: 8 | 9 | - `main`分支不作为部署分支 10 | 11 | - `gemini-tts` 分支部署的是 Gemini TTS 12 | 13 | - `openai-tts` 分支部署的是 OpenAI TTS 14 | 15 | - `ms-tts` 分支部署的是 Microsoft Azure TTS (原项目:[zuoban/tts: tts 服务](https://github.com/zuoban/tts?tab=readme-ov-file)) 16 | - 添加环境变量`API_KEY` (限制用户使用,当设置了`API_KEY`之后,用户在前端页面访问时,需要设置一样的`API Key 设置`才能访问) 17 | 18 |  19 | - Workers & Pages -> Your Worker -> Settings -> Variables and Secrets -> Add 20 | - Type: `Secret`, Name: `API_KEY`, Value: `YOUR_API_KEY` 21 | 22 | 23 | 24 | ## 说明 25 | 26 | `gemini-tts` 和`openai-tts`使用时是需要用户在前端界面设置自己的`gemini api-key` 以及`openai api-key` 27 |  28 |  29 | 30 | 31 | 32 | 参考: 33 | - [【分享】自建TTS服务 - 高质量文本转语音工具 - 资源荟萃 - LINUX DO](https://linux.do/t/topic/482507) 34 | - [Gemini TTS Client开源了! - 资源荟萃 - LINUX DO](https://linux.do/t/topic/1015686) 35 | - [OpenAI TTS源码新版来了 - 开发调优 - LINUX DO](https://linux.do/t/topic/425924) -------------------------------------------------------------------------------- /Gemini TTS.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
|<\/p>/g }, 20 | { name: 's', pattern: /|<\/s>/g }, 21 | { name: 'sub', pattern: /]*>|<\/sub>/g }, 22 | { name: 'mstts', pattern: /]*>|<\/mstts:[^>]*>/g } 23 | ]; 24 | 25 | function uuid(){ 26 | return crypto.randomUUID().replace(/-/g, '') 27 | } 28 | 29 | // EscapeSSML 转义 SSML 内容,但保留配置的标签 30 | function escapeSSML(ssml) { 31 | // 使用占位符替换标签 32 | let placeholders = new Map(); 33 | let processedSSML = ssml; 34 | let counter = 0; 35 | 36 | // 处理所有配置的标签 37 | for (const tag of preserveTags) { 38 | processedSSML = processedSSML.replace(tag.pattern, function(match) { 39 | const placeholder = `__SSML_PLACEHOLDER_${tag.name}_${counter++}__`; 40 | placeholders.set(placeholder, match); 41 | return placeholder; 42 | }); 43 | } 44 | 45 | // 对处理后的文本进行HTML转义 46 | let escapedContent = escapeBasicXml(processedSSML); 47 | 48 | // 恢复所有标签占位符 49 | placeholders.forEach((tag, placeholder) => { 50 | escapedContent = escapedContent.replace(placeholder, tag); 51 | }); 52 | 53 | return escapedContent; 54 | } 55 | 56 | // 基本 XML 转义功能,只处理基本字符 57 | function escapeBasicXml(unsafe) { 58 | return unsafe.replace(/[<>&'"]/g, function (c) { 59 | switch (c) { 60 | case '<': return '<'; 61 | case '>': return '>'; 62 | case '&': return '&'; 63 | case '\'': return '''; 64 | case '"': return '"'; 65 | } 66 | }); 67 | } 68 | 69 | async function handleRequest(request) { 70 | const requestUrl = new URL(request.url); 71 | const path = requestUrl.pathname; 72 | 73 | if (path === '/tts') { 74 | // 从请求参数获取 API 密钥 75 | const apiKey = requestUrl.searchParams.get('api_key'); 76 | 77 | // 验证 API 密钥 78 | if (!validateApiKey(apiKey)) { 79 | // 改进 401 错误响应,提供更友好的错误信息 80 | return new Response(JSON.stringify({ 81 | error: 'Unauthorized', 82 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 83 | status: 401 84 | }), { 85 | status: 401, 86 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 87 | }); 88 | } 89 | 90 | const text = requestUrl.searchParams.get('t') || ''; 91 | const voiceName = requestUrl.searchParams.get('v') || 'zh-CN-XiaoxiaoMultilingualNeural'; 92 | const rate = Number(requestUrl.searchParams.get('r')) || 0; 93 | const pitch = Number(requestUrl.searchParams.get('p')) || 0; 94 | const style = requestUrl.searchParams.get('s') || 'general'; 95 | const outputFormat = requestUrl.searchParams.get('o') || 'audio-24khz-48kbitrate-mono-mp3'; 96 | const download = requestUrl.searchParams.get('d') || false; 97 | const response = await getVoice(text, voiceName, rate, pitch, style, outputFormat, download); 98 | return response; 99 | } 100 | 101 | // 添加 OpenAI 兼容接口路由 102 | if (path === '/v1/audio/speech' || path === '/audio/speech') { 103 | return await handleOpenAITTS(request); 104 | } 105 | 106 | if(path === '/voices') { 107 | const l = (requestUrl.searchParams.get('l') || '').toLowerCase(); 108 | const f = requestUrl.searchParams.get('f'); 109 | let response = await voiceList(); 110 | 111 | if(l.length > 0) { 112 | response = response.filter(item => item.Locale.toLowerCase().includes(l)); 113 | } 114 | 115 | return new Response(JSON.stringify(response), { 116 | headers:{ 117 | 'Content-Type': 'application/json; charset=utf-8' 118 | } 119 | }); 120 | } 121 | 122 | const baseUrl = request.url.split('://')[0] + "://" +requestUrl.host; 123 | return new Response(` 124 | 125 | 126 | 127 | 128 | 129 | Microsoft TTS API 130 | 131 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | Microsoft TTS API 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 在线文本转语音 164 | API文档 165 | 关于服务 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 在线文本转语音 177 | 输入文本并选择语音进行转换 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 错误 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | API Key 195 | 196 | 197 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 保存 209 | 210 | 211 | 212 | 213 | API Key 已保存 214 | 215 | 216 | 编辑 217 | 218 | 219 | 220 | 221 | 222 | 输入文本 223 | 226 | 227 | 228 | 229 | 230 | 选择语音 231 | 233 | 234 | 235 | 236 | 237 | 语音风格 238 | 240 | 标准 241 | 广告热情 242 | 亲切 243 | 愤怒 244 | 助理 245 | 平静 246 | 随意 247 | 愉快 248 | 客服 249 | 沮丧 250 | 不满 251 | 纪录片解说 252 | 尴尬 253 | 共情 254 | 羡慕 255 | 兴奋 256 | 恐惧 257 | 友好 258 | 温柔 259 | 希望 260 | 抒情 261 | 专业叙述 262 | 轻松叙述 263 | 新闻播报 264 | 随意新闻 265 | 正式新闻 266 | 诗朗诵 267 | 悲伤 268 | 严肃 269 | 大喊 270 | 体育解说 271 | 激动体育解说 272 | 低语 273 | 恐慌 274 | 冷漠 275 | 276 | 277 | 278 | 279 | 280 | 281 | 语速调整 282 | 283 | 0 284 | 286 | 287 | 288 | 289 | 290 | 音调调整 291 | 292 | 0 293 | 295 | 296 | 297 | 298 | 299 | 300 | 302 | 生成语音 303 | 304 | 306 | 下载音频 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 无法加载语音列表 319 | 320 | 显示默认语音列表。请检查网络连接或稍后再试。 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | API 文档 343 | 344 | 345 | 文本转语音 API 346 | /tts?api_key={key}&t={text}&v={voice}&r={rate}&p={pitch}&s={style} 347 | 348 | api_key: API密钥 [必填] 349 | t: 文本内容 [必填] 350 | v: 语音名称 [可选] 351 | r: 语速调整 (-100~100) [可选] 352 | p: 音调调整 (-100~100) [可选] 353 | s: 语音风格 (general, cheerful, sad等) [可选] 354 | 355 | 356 | OpenAI 兼容接口 357 | /v1/audio/speech 或 /audio/speech 358 | 使用方式与 OpenAI TTS API 相同,支持以下参数: 359 | 360 | model: 模型名称 [必填] 361 | input: 文本内容 [必填] 362 | voice: 声音类型 (alloy, echo, fable, onyx, nova, shimmer) 363 | speed: 语速 (0.25~4.0) 364 | response_format: 输出格式 (mp3, opus) 365 | 366 | 367 | 368 | 示例请求: 369 | 370 | curl ${baseUrl}/v1/audio/speech \\ 371 | -H "Authorization: Bearer your-secret-api-key" \\ 372 | -H "Content-Type: application/json" \\ 373 | -d '{ 374 | "model": "tts-1", 375 | "input": "这是一个语音合成测试", 376 | "voice": "alloy" 377 | }' 378 | 379 | 380 | 381 | 获取语音列表 API 382 | /voices?l={locale}&f={format} 383 | 384 | l: 语言筛选 (如 'zh', 'en') 385 | f: 返回格式 (0=TTS格式, 1=JSON格式) 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 重要提示 397 | 398 | 399 | 所有请求必须提供有效的 API 密钥 400 | 请确保中文文本进行 URL 编码 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 关于服务 418 | 419 | 420 | Microsoft TTS API 是一个高质量的文本转语音服务,支持多种语言和声音。 421 | 通过简单的 API 调用,可以将文本转换为自然流畅的语音。 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 支持 SSML 标签和 OpenAI 兼容接口 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 450 | 451 | 802 | 803 | 804 | `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8'}}); 805 | } 806 | 807 | addEventListener('fetch', event => { 808 | event.respondWith(handleRequest(event.request)); 809 | }); 810 | 811 | 812 | 813 | async function getEndpoint() { 814 | const endpointUrl = 'https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0'; 815 | const headers = { 816 | 'Accept-Language': 'zh-Hans', 817 | 'X-ClientVersion': '4.0.530a 5fe1dc6c', 818 | 'X-UserId': generateUserId(), // 使用随机生成的UserId 819 | 'X-HomeGeographicRegion': 'zh-Hans-CN', 820 | 'X-ClientTraceId': uuid(), // 直接使用uuid函数生成 821 | 822 | 'X-MT-Signature': await sign(endpointUrl), 823 | 'User-Agent': 'okhttp/4.5.0', 824 | 'Content-Type': 'application/json; charset=utf-8', 825 | 'Content-Length': '0', 826 | 'Accept-Encoding': 'gzip' 827 | }; 828 | 829 | return fetch(endpointUrl, { 830 | method: 'POST', 831 | headers: headers 832 | }).then(res => res.json()); 833 | } 834 | 835 | // 随机生成 X-UserId,格式为 16 位字符(字母+数字) 836 | function generateUserId() { 837 | const chars = 'abcdef0123456789'; // 只使用16进制字符,与原格式一致 838 | let result = ''; 839 | for (let i = 0; i < 16; i++) { 840 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 841 | } 842 | return result; 843 | } 844 | 845 | async function sign(urlStr) { 846 | const url = urlStr.split('://')[1]; 847 | const encodedUrl = encodeURIComponent(url); 848 | const uuidStr = uuid(); 849 | const formattedDate = dateFormat(); 850 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 851 | const decode = await base64ToBytes('oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=='); 852 | const signData = await hmacSha256(decode, bytesToSign); 853 | const signBase64 = await bytesToBase64(signData); 854 | return `MSTranslatorAndroidApp::${signBase64}::${formattedDate}::${uuidStr}`; 855 | } 856 | 857 | function dateFormat() { 858 | const formattedDate = new Date().toUTCString().replace(/GMT/, '').trim() + 'GMT'; 859 | return formattedDate.toLowerCase(); 860 | } 861 | 862 | function getSsml(text, voiceName, rate, pitch, style = 'general') { 863 | text = escapeSSML(text); 864 | return ` ${text} `; 865 | } 866 | 867 | function voiceList() { 868 | // 检查缓存是否有效 869 | if (voiceListCache && voiceListCacheTime && (Date.now() - voiceListCacheTime) < VOICE_CACHE_DURATION) { 870 | console.log('使用缓存的语音列表数据,剩余有效期:', 871 | Math.round((VOICE_CACHE_DURATION - (Date.now() - voiceListCacheTime)) / 60000), '分钟'); 872 | return Promise.resolve(voiceListCache); 873 | } 874 | 875 | console.log('获取新的语音列表数据'); 876 | const headers = { 877 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26', 878 | 'X-Ms-Useragent': 'SpeechStudio/2021.05.001', 879 | 'Content-Type': 'application/json', 880 | 'Origin': 'https://azure.microsoft.com', 881 | 'Referer': 'https://azure.microsoft.com' 882 | }; 883 | 884 | return fetch('https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list', { 885 | headers: headers, 886 | }) 887 | .then(res => res.json()) 888 | .then(data => { 889 | // 更新缓存 890 | voiceListCache = data; 891 | voiceListCacheTime = Date.now(); 892 | return data; 893 | }); 894 | } 895 | 896 | async function hmacSha256(key, data) { 897 | const cryptoKey = await crypto.subtle.importKey( 898 | 'raw', 899 | key, 900 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 901 | false, 902 | ['sign'] 903 | ); 904 | const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)); 905 | return new Uint8Array(signature); 906 | } 907 | 908 | async function base64ToBytes(base64) { 909 | const binaryString = atob(base64); 910 | const bytes = new Uint8Array(binaryString.length); 911 | for (let i = 0; i < binaryString.length; i++) { 912 | bytes[i] = binaryString.charCodeAt(i); 913 | } 914 | return bytes; 915 | } 916 | 917 | async function bytesToBase64(bytes) { 918 | const base64 = btoa(String.fromCharCode.apply(null, bytes)); 919 | return base64; 920 | } 921 | 922 | 923 | 924 | // API 密钥验证函数 925 | function validateApiKey(apiKey) { 926 | // 从环境变量获取 API 密钥并进行验证 927 | const expectedApiKey = API_KEY || ''; 928 | return apiKey === expectedApiKey; 929 | } 930 | 931 | async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, style = 'general', outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { 932 | // get expiredAt from endpoint.t (jwt token) 933 | if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { 934 | endpoint = await getEndpoint(); 935 | const jwt = endpoint.t.split('.')[1]; 936 | const decodedJwt = JSON.parse(atob(jwt)); 937 | expiredAt = decodedJwt.exp; 938 | const seconds = (expiredAt - Date.now() / 1000); 939 | clientId = uuid(); 940 | console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') 941 | } else { 942 | const seconds = (expiredAt - Date.now() / 1000); 943 | console.log('expiredAt:' + (seconds/ 60) + 'm left') 944 | } 945 | 946 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 947 | const headers = { 948 | 'Authorization': endpoint.t, 949 | 'Content-Type': 'application/ssml+xml', 950 | 'User-Agent': 'okhttp/4.5.0', 951 | 'X-Microsoft-OutputFormat': outputFormat 952 | }; 953 | const ssml = getSsml(text, voiceName, rate, pitch, style); 954 | 955 | const response = await fetch(url, { 956 | method: 'POST', 957 | headers: headers, 958 | body: ssml 959 | }); 960 | if(response.ok) { 961 | if (!download) { 962 | return response; 963 | } 964 | const resp = new Response(response.body, response); 965 | resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); 966 | return resp; 967 | } else { 968 | return new Response(response.statusText, { status: response.status }); 969 | } 970 | } 971 | 972 | // 处理 OpenAI 格式的文本转语音请求 973 | async function handleOpenAITTS(request) { 974 | // 验证请求方法是否为 POST 975 | if (request.method !== 'POST') { 976 | return new Response(JSON.stringify({ error: 'Method not allowed' }), { 977 | status: 405, 978 | headers: { 'Content-Type': 'application/json' } 979 | }); 980 | } 981 | 982 | // 验证 API 密钥 983 | const authHeader = request.headers.get('Authorization'); 984 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 985 | return new Response(JSON.stringify({ error: 'Unauthorized: Missing or invalid API key' }), { 986 | status: 401, 987 | headers: { 'Content-Type': 'application/json' } 988 | }); 989 | } 990 | 991 | const apiKey = authHeader.replace('Bearer ', ''); 992 | if (!validateApiKey(apiKey)) { 993 | return new Response(JSON.stringify({ error: 'Unauthorized: Invalid API key' }), { 994 | status: 401, 995 | headers: { 'Content-Type': 'application/json' } 996 | }); 997 | } 998 | 999 | try { 1000 | // 解析请求体 JSON 1001 | const requestData = await request.json(); 1002 | 1003 | // 验证必要参数 1004 | if (!requestData.model || !requestData.input) { 1005 | return new Response(JSON.stringify({ error: 'Bad request: Missing required parameters' }), { 1006 | status: 400, 1007 | headers: { 'Content-Type': 'application/json' } 1008 | }); 1009 | } 1010 | 1011 | // 提取参数 1012 | const text = requestData.input; 1013 | // 映射 voice 参数 (可选择添加 model 到 voice 的映射逻辑) 1014 | let voiceName = 'zh-CN-XiaoxiaoMultilingualNeural'; // 默认声音 1015 | if (requestData.voice) { 1016 | // OpenAI的voice参数有alloy, echo, fable, onyx, nova, shimmer 1017 | // 可以根据需要进行映射 1018 | const voiceMap = { 1019 | 'alloy': 'zh-CN-XiaoxiaoMultilingualNeural', 1020 | 'echo': 'zh-CN-YunxiNeural', 1021 | 'fable': 'zh-CN-XiaomoNeural', 1022 | 'onyx': 'zh-CN-YunjianNeural', 1023 | 'nova': 'zh-CN-XiaochenNeural', 1024 | 'shimmer': 'en-US-AriaNeural' 1025 | }; 1026 | voiceName = voiceMap[requestData.voice] || requestData.voice; 1027 | } 1028 | 1029 | // 速度和音调映射 (OpenAI 使用 0.25-4.0,我们使用 -100 到 100) 1030 | let rate = 0; 1031 | if (requestData.speed) { 1032 | // 映射 0.25-4.0 到 -100 到 100 范围 1033 | // 1.0 是正常速度,对应 rate=0 1034 | rate = Math.round((requestData.speed - 1.0) * 100); 1035 | // 限制范围 1036 | rate = Math.max(-100, Math.min(100, rate)); 1037 | } 1038 | 1039 | // 设置输出格式 1040 | const outputFormat = requestData.response_format === 'opus' ? 1041 | 'audio-48khz-192kbitrate-mono-opus' : 1042 | 'audio-24khz-48kbitrate-mono-mp3'; 1043 | 1044 | // 调用 TTS API 1045 | const ttsResponse = await getVoice(text, voiceName, rate, 0, outputFormat, false); 1046 | 1047 | return ttsResponse; 1048 | } catch (error) { 1049 | console.error('OpenAI TTS API error:', error); 1050 | return new Response(JSON.stringify({ error: 'Internal server error: ' + error.message }), { 1051 | status: 500, 1052 | headers: { 'Content-Type': 'application/json' } 1053 | }); 1054 | } 1055 | } -------------------------------------------------------------------------------- /OpenAI TTS.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OpenAI TTS 5 | 6 | 230 | 231 | 232 | 233 | OpenAI TTS 234 | 235 | ⚙️ 236 | 237 | 238 | 239 | 配置 240 | API基础URL: 241 | 242 | 243 | API密钥: 244 | 245 | 246 | 模型: 247 | 248 | FunAudioLLM/CosyVoice2-0.5B 249 | RVC-Boss/GPT-SoVITS 250 | LoRA/RVC-Boss/GPT-SoVITS 251 | 252 | 253 | 语音: 254 | 255 | Onyx 256 | 257 | 258 | 259 | 260 | 生成语音 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | ▶ 270 | 271 | 272 | 273 | 274 | ⤓ 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 433 | 434 | -------------------------------------------------------------------------------- /Microsoft Azure TTS-V2.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | let expiredAt = null; 3 | let endpoint = null; 4 | // 添加缓存相关变量 5 | let voiceListCache = null; 6 | let voiceListCacheTime = null; 7 | const VOICE_CACHE_DURATION =4 *60 * 60 * 1000; // 4小时,单位毫秒 8 | 9 | // 定义需要保留的 SSML 标签模式 10 | const preserveTags = [ 11 | { name: 'break', pattern: /]*\/>/g }, 12 | { name: 'speak', pattern: /|<\/speak>/g }, 13 | { name: 'prosody', pattern: /]*>|<\/prosody>/g }, 14 | { name: 'emphasis', pattern: /]*>|<\/emphasis>/g }, 15 | { name: 'voice', pattern: /]*>|<\/voice>/g }, 16 | { name: 'say-as', pattern: /]*>|<\/say-as>/g }, 17 | { name: 'phoneme', pattern: /]*>|<\/phoneme>/g }, 18 | { name: 'audio', pattern: /]*>|<\/audio>/g }, 19 | { name: 'p', pattern: /|<\/p>/g }, 20 | { name: 's', pattern: /|<\/s>/g }, 21 | { name: 'sub', pattern: /]*>|<\/sub>/g }, 22 | { name: 'mstts', pattern: /]*>|<\/mstts:[^>]*>/g } 23 | ]; 24 | 25 | function uuid(){ 26 | return crypto.randomUUID().replace(/-/g, '') 27 | } 28 | 29 | // EscapeSSML 转义 SSML 内容,但保留配置的标签 30 | function escapeSSML(ssml) { 31 | // 使用占位符替换标签 32 | let placeholders = new Map(); 33 | let processedSSML = ssml; 34 | let counter = 0; 35 | 36 | // 处理所有配置的标签 37 | for (const tag of preserveTags) { 38 | processedSSML = processedSSML.replace(tag.pattern, function(match) { 39 | const placeholder = `__SSML_PLACEHOLDER_${tag.name}_${counter++}__`; 40 | placeholders.set(placeholder, match); 41 | return placeholder; 42 | }); 43 | } 44 | 45 | // 对处理后的文本进行HTML转义 46 | let escapedContent = escapeBasicXml(processedSSML); 47 | 48 | // 恢复所有标签占位符 49 | placeholders.forEach((tag, placeholder) => { 50 | escapedContent = escapedContent.replace(placeholder, tag); 51 | }); 52 | 53 | return escapedContent; 54 | } 55 | 56 | // 基本 XML 转义功能,只处理基本字符 57 | function escapeBasicXml(unsafe) { 58 | return unsafe.replace(/[<>&'"]/g, function (c) { 59 | switch (c) { 60 | case '<': return '<'; 61 | case '>': return '>'; 62 | case '&': return '&'; 63 | case '\'': return '''; 64 | case '"': return '"'; 65 | } 66 | }); 67 | } 68 | 69 | async function handleRequest(request) { 70 | const requestUrl = new URL(request.url); 71 | const path = requestUrl.pathname; 72 | 73 | if (path === '/tts') { 74 | // 从请求参数获取 API 密钥 75 | const apiKey = requestUrl.searchParams.get('api_key'); 76 | 77 | // 验证 API 密钥 78 | if (!validateApiKey(apiKey)) { 79 | // 改进 401 错误响应,提供更友好的错误信息 80 | return new Response(JSON.stringify({ 81 | error: 'Unauthorized', 82 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 83 | status: 401 84 | }), { 85 | status: 401, 86 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 87 | }); 88 | } 89 | 90 | const text = requestUrl.searchParams.get('t') || ''; 91 | const voiceName = requestUrl.searchParams.get('v') || 'zh-CN-XiaoxiaoMultilingualNeural'; 92 | const rate = Number(requestUrl.searchParams.get('r')) || 0; 93 | const pitch = Number(requestUrl.searchParams.get('p')) || 0; 94 | const style = requestUrl.searchParams.get('s') || 'general'; 95 | const outputFormat = requestUrl.searchParams.get('o') || 'audio-24khz-48kbitrate-mono-mp3'; 96 | const download = requestUrl.searchParams.get('d') || false; 97 | const response = await getVoice(text, voiceName, rate, pitch, style, outputFormat, download); 98 | return response; 99 | } 100 | 101 | // 添加 reader.json 路径处理 102 | if (path === '/reader.json') { 103 | // 从请求参数获取 API 密钥 104 | const apiKey = requestUrl.searchParams.get('api_key'); 105 | 106 | // 验证 API 密钥 107 | if (!validateApiKey(apiKey)) { 108 | return new Response(JSON.stringify({ 109 | error: 'Unauthorized', 110 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 111 | status: 401 112 | }), { 113 | status: 401, 114 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 115 | }); 116 | } 117 | 118 | // 从URL参数获取 119 | const voice = requestUrl.searchParams.get('v') || ''; 120 | const rate = requestUrl.searchParams.get('r') || ''; 121 | const pitch = requestUrl.searchParams.get('p') || ''; 122 | const style = requestUrl.searchParams.get('s') || ''; 123 | const displayName = requestUrl.searchParams.get('n') || 'Microsoft TTS'; 124 | 125 | // 构建基本URL 126 | const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`; 127 | 128 | // 构建URL参数 129 | const urlParams = ["t={{java.encodeURI(speakText)}}", "r={{speakSpeed*4}}"]; 130 | 131 | // 只有有值的参数才添加 132 | if (voice) { 133 | urlParams.push(`v=${voice}`); 134 | } 135 | 136 | if (pitch) { 137 | urlParams.push(`p=${pitch}`); 138 | } 139 | 140 | if (style) { 141 | urlParams.push(`s=${style}`); 142 | } 143 | 144 | if (apiKey) { 145 | urlParams.push(`api_key=${apiKey}`); 146 | } 147 | 148 | const url = `${baseUrl}/tts?${urlParams.join('&')}`; 149 | 150 | // 返回 reader 响应 151 | return new Response(JSON.stringify({ 152 | id: Date.now(), 153 | name: displayName, 154 | url: url 155 | }), { 156 | status: 200, 157 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 158 | }); 159 | } 160 | 161 | // 添加 ifreetime.json 路径处理 162 | if (path === '/ifreetime.json') { 163 | // 从请求参数获取 API 密钥 164 | const apiKey = requestUrl.searchParams.get('api_key'); 165 | 166 | // 验证 API 密钥 167 | if (!validateApiKey(apiKey)) { 168 | return new Response(JSON.stringify({ 169 | error: 'Unauthorized', 170 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 171 | status: 401 172 | }), { 173 | status: 401, 174 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 175 | }); 176 | } 177 | 178 | // 从URL参数获取 179 | const voice = requestUrl.searchParams.get('v') || ''; 180 | const rate = requestUrl.searchParams.get('r') || ''; 181 | const pitch = requestUrl.searchParams.get('p') || ''; 182 | const style = requestUrl.searchParams.get('s') || ''; 183 | const displayName = requestUrl.searchParams.get('n') || 'Microsoft TTS'; 184 | 185 | // 构建基本URL 186 | const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`; 187 | const url = `${baseUrl}/tts`; 188 | 189 | // 生成随机的唯一ID 190 | const ttsConfigID = crypto.randomUUID(); 191 | 192 | // 构建请求参数 193 | const params = { 194 | "t": "%@", // %@ 是 IFreeTime 中的文本占位符 195 | "v": voice, 196 | "r": rate, 197 | "p": pitch, 198 | "s": style 199 | }; 200 | 201 | // 如果需要API密钥认证,添加到请求参数 202 | if (apiKey) { 203 | params["api_key"] = apiKey; 204 | } 205 | 206 | // 构建响应 207 | const response = { 208 | loginUrl: "", 209 | maxWordCount: "", 210 | customRules: {}, 211 | ttsConfigGroup: "Azure", 212 | _TTSName: displayName, 213 | _ClassName: "JxdAdvCustomTTS", 214 | _TTSConfigID: ttsConfigID, 215 | httpConfigs: { 216 | useCookies: 1, 217 | headers: {} 218 | }, 219 | voiceList: [], 220 | ttsHandles: [ 221 | { 222 | paramsEx: "", 223 | processType: 1, 224 | maxPageCount: 1, 225 | nextPageMethod: 1, 226 | method: 1, 227 | requestByWebView: 0, 228 | parser: {}, 229 | nextPageParams: {}, 230 | url: url, 231 | params: params, 232 | httpConfigs: { 233 | useCookies: 1, 234 | headers: {} 235 | } 236 | } 237 | ] 238 | }; 239 | 240 | // 返回 IFreeTime 响应 241 | return new Response(JSON.stringify(response), { 242 | status: 200, 243 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 244 | }); 245 | } 246 | 247 | // 添加 OpenAI 兼容接口路由 248 | if (path === '/v1/audio/speech' || path === '/audio/speech') { 249 | return await handleOpenAITTS(request); 250 | } 251 | 252 | if(path === '/voices') { 253 | const l = (requestUrl.searchParams.get('l') || '').toLowerCase(); 254 | const f = requestUrl.searchParams.get('f'); 255 | let response = await voiceList(); 256 | 257 | if(l.length > 0) { 258 | response = response.filter(item => item.Locale.toLowerCase().includes(l)); 259 | } 260 | 261 | return new Response(JSON.stringify(response), { 262 | headers:{ 263 | 'Content-Type': 'application/json; charset=utf-8' 264 | } 265 | }); 266 | } 267 | 268 | const baseUrl = request.url.split('://')[0] + "://" +requestUrl.host; 269 | return new Response(` 270 | 271 | 272 | 273 | 274 | 275 | Microsoft TTS API 276 | 277 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | Microsoft TTS API 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 在线文本转语音 313 | 输入文本并选择语音进行转换 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 错误 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | API Key 331 | 332 | 333 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 保存 345 | 346 | 347 | 348 | 349 | API Key 已保存 350 | 351 | 352 | 编辑 353 | 354 | 355 | 356 | 357 | 358 | 输入文本 359 | 362 | 363 | 364 | 365 | 366 | 选择语音 367 | 369 | 370 | 371 | 372 | 373 | 语音风格 374 | 376 | 标准 377 | 广告热情 378 | 亲切 379 | 愤怒 380 | 助理 381 | 平静 382 | 随意 383 | 愉快 384 | 客服 385 | 沮丧 386 | 不满 387 | 纪录片解说 388 | 尴尬 389 | 共情 390 | 羡慕 391 | 兴奋 392 | 恐惧 393 | 友好 394 | 温柔 395 | 希望 396 | 抒情 397 | 专业叙述 398 | 轻松叙述 399 | 新闻播报 400 | 随意新闻 401 | 正式新闻 402 | 诗朗诵 403 | 悲伤 404 | 严肃 405 | 大喊 406 | 体育解说 407 | 激动体育解说 408 | 低语 409 | 恐慌 410 | 冷漠 411 | 412 | 413 | 414 | 415 | 416 | 417 | 语速调整 418 | 419 | 0 420 | 422 | 423 | 424 | 425 | 426 | 音调调整 427 | 428 | 0 429 | 431 | 432 | 433 | 434 | 435 | 436 | 438 | 生成语音 439 | 440 | 442 | 下载音频 443 | 444 | 446 | 导入阅读 447 | 448 | 450 | 导入爱阅记 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 无法加载语音列表 462 | 463 | 显示默认语音列表。请检查网络连接或稍后再试。 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 494 | 495 | 974 | 975 | 976 | `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8'}}); 977 | } 978 | 979 | addEventListener('fetch', event => { 980 | event.respondWith(handleRequest(event.request)); 981 | }); 982 | 983 | 984 | async function getEndpoint() { 985 | const endpointUrl = 'https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0'; 986 | const headers = { 987 | 'Accept-Language': 'zh-Hans', 988 | 'X-ClientVersion': '4.0.530a 5fe1dc6c', 989 | 'X-UserId': generateUserId(), // 使用随机生成的UserId 990 | 'X-HomeGeographicRegion': 'zh-Hans-CN', 991 | 'X-ClientTraceId': uuid(), // 直接使用uuid函数生成 992 | 993 | 'X-MT-Signature': await sign(endpointUrl), 994 | 'User-Agent': 'okhttp/4.5.0', 995 | 'Content-Type': 'application/json', 996 | 'Content-Length': '0', 997 | 'Accept-Encoding': 'gzip' 998 | }; 999 | 1000 | return fetch(endpointUrl, { 1001 | method: 'POST', 1002 | headers: headers 1003 | }).then(res => res.json()); 1004 | } 1005 | 1006 | // 随机生成 X-UserId,格式为 16 位字符(字母+数字) 1007 | function generateUserId() { 1008 | const chars = 'abcdef0123456789'; // 只使用16进制字符,与原格式一致 1009 | let result = ''; 1010 | for (let i = 0; i < 16; i++) { 1011 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 1012 | } 1013 | return result; 1014 | } 1015 | 1016 | async function sign(urlStr) { 1017 | const url = urlStr.split('://')[1]; 1018 | const encodedUrl = encodeURIComponent(url); 1019 | const uuidStr = uuid(); 1020 | const formattedDate = dateFormat(); 1021 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 1022 | const decode = await base64ToBytes('oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=='); 1023 | const signData = await hmacSha256(decode, bytesToSign); 1024 | const signBase64 = await bytesToBase64(signData); 1025 | return `MSTranslatorAndroidApp::${signBase64}::${formattedDate}::${uuidStr}`; 1026 | } 1027 | 1028 | function dateFormat() { 1029 | const formattedDate = new Date().toUTCString().replace(/GMT/, '').trim() + 'GMT'; 1030 | return formattedDate.toLowerCase(); 1031 | } 1032 | 1033 | function getSsml(text, voiceName, rate, pitch, style = 'general') { 1034 | text = escapeSSML(text); 1035 | return ` ${text} `; 1036 | } 1037 | 1038 | function voiceList() { 1039 | // 检查缓存是否有效 1040 | if (voiceListCache && voiceListCacheTime && (Date.now() - voiceListCacheTime) < VOICE_CACHE_DURATION) { 1041 | console.log('使用缓存的语音列表数据,剩余有效���:', 1042 | Math.round((VOICE_CACHE_DURATION - (Date.now() - voiceListCacheTime)) / 60000), '分钟'); 1043 | return Promise.resolve(voiceListCache); 1044 | } 1045 | 1046 | console.log('获取新的语音列表数据'); 1047 | const headers = { 1048 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26', 1049 | 'X-Ms-Useragent': 'SpeechStudio/2021.05.001', 1050 | 'Content-Type': 'application/json', 1051 | 'Origin': 'https://azure.microsoft.com', 1052 | 'Referer': 'https://azure.microsoft.com' 1053 | }; 1054 | 1055 | return fetch('https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list', { 1056 | headers: headers, 1057 | }) 1058 | .then(res => res.json()) 1059 | .then(data => { 1060 | // 更新缓存 1061 | voiceListCache = data; 1062 | voiceListCacheTime = Date.now(); 1063 | return data; 1064 | }); 1065 | } 1066 | 1067 | async function hmacSha256(key, data) { 1068 | const cryptoKey = await crypto.subtle.importKey( 1069 | 'raw', 1070 | key, 1071 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 1072 | false, 1073 | ['sign'] 1074 | ); 1075 | const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)); 1076 | return new Uint8Array(signature); 1077 | } 1078 | 1079 | async function base64ToBytes(base64) { 1080 | const binaryString = atob(base64); 1081 | const bytes = new Uint8Array(binaryString.length); 1082 | for (let i = 0; i < binaryString.length; i++) { 1083 | bytes[i] = binaryString.charCodeAt(i); 1084 | } 1085 | return bytes; 1086 | } 1087 | 1088 | async function bytesToBase64(bytes) { 1089 | const base64 = btoa(String.fromCharCode.apply(null, bytes)); 1090 | return base64; 1091 | } 1092 | 1093 | 1094 | 1095 | // API 密钥验证函数 1096 | function validateApiKey(apiKey) { 1097 | // 从环境变量获取 API 密钥并进行验证 1098 | const expectedApiKey = API_KEY || ''; 1099 | return apiKey === expectedApiKey; 1100 | } 1101 | 1102 | async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, style = 'general', outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { 1103 | // get expiredAt from endpoint.t (jwt token) 1104 | if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { 1105 | endpoint = await getEndpoint(); 1106 | const jwt = endpoint.t.split('.')[1]; 1107 | const decodedJwt = JSON.parse(atob(jwt)); 1108 | expiredAt = decodedJwt.exp; 1109 | const seconds = (expiredAt - Date.now() / 1000); 1110 | clientId = uuid(); 1111 | console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') 1112 | } else { 1113 | const seconds = (expiredAt - Date.now() / 1000); 1114 | console.log('expiredAt:' + (seconds/ 60) + 'm left') 1115 | } 1116 | 1117 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 1118 | const headers = { 1119 | 'Authorization': endpoint.t, 1120 | 'Content-Type': 'application/ssml+xml', 1121 | 'User-Agent': 'okhttp/4.5.0', 1122 | 'X-Microsoft-OutputFormat': outputFormat 1123 | }; 1124 | const ssml = getSsml(text, voiceName, rate, pitch, style); 1125 | 1126 | const response = await fetch(url, { 1127 | method: 'POST', 1128 | headers: headers, 1129 | body: ssml 1130 | }); 1131 | if(response.ok) { 1132 | if (!download) { 1133 | return response; 1134 | } 1135 | const resp = new Response(response.body, response); 1136 | resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); 1137 | return resp; 1138 | } else { 1139 | return new Response(response.statusText, { status: response.status }); 1140 | } 1141 | } 1142 | 1143 | // 处理 OpenAI 格式的文本转语音请求 1144 | async function handleOpenAITTS(request) { 1145 | // 验证请求方法是否为 POST 1146 | if (request.method !== 'POST') { 1147 | return new Response(JSON.stringify({ error: 'Method not allowed' }), { 1148 | status: 405, 1149 | headers: { 'Content-Type': 'application/json' } 1150 | }); 1151 | } 1152 | 1153 | // 验证 API 密钥 1154 | const authHeader = request.headers.get('Authorization'); 1155 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 1156 | return new Response(JSON.stringify({ error: 'Unauthorized: Missing or invalid API key' }), { 1157 | status: 401, 1158 | headers: { 'Content-Type': 'application/json' } 1159 | }); 1160 | } 1161 | 1162 | const apiKey = authHeader.replace('Bearer ', ''); 1163 | if (!validateApiKey(apiKey)) { 1164 | return new Response(JSON.stringify({ error: 'Unauthorized: Invalid API key' }), { 1165 | status: 401, 1166 | headers: { 'Content-Type': 'application/json' } 1167 | }); 1168 | } 1169 | 1170 | try { 1171 | // 解析请求体 JSON 1172 | const requestData = await request.json(); 1173 | 1174 | // 验证必要参数 1175 | if (!requestData.model || !requestData.input) { 1176 | return new Response(JSON.stringify({ error: 'Bad request: Missing required parameters' }), { 1177 | status: 400, 1178 | headers: { 'Content-Type': 'application/json' } 1179 | }); 1180 | } 1181 | 1182 | // 提取参数 1183 | const text = requestData.input; 1184 | // 映射 voice 参数 (可选择添加 model 到 voice 的映射逻辑) 1185 | let voiceName = 'zh-CN-XiaoxiaoMultilingualNeural'; // 默认声音 1186 | if (requestData.voice) { 1187 | // OpenAI的voice参数有alloy, echo, fable, onyx, nova, shimmer 1188 | // 可以根据需要进行映射 1189 | const voiceMap = { 1190 | 'alloy': 'zh-CN-XiaoxiaoMultilingualNeural', 1191 | 'echo': 'zh-CN-YunxiNeural', 1192 | 'fable': 'zh-CN-XiaomoNeural', 1193 | 'onyx': 'zh-CN-YunjianNeural', 1194 | 'nova': 'zh-CN-XiaochenNeural', 1195 | 'shimmer': 'en-US-AriaNeural' 1196 | }; 1197 | voiceName = voiceMap[requestData.voice] || requestData.voice; 1198 | } 1199 | 1200 | // 速度和音调映射 (OpenAI 使用 0.25-4.0,我们使用 -100 到 100) 1201 | let rate = 0; 1202 | if (requestData.speed) { 1203 | // 映射 0.25-4.0 到 -100 到 100 范围 1204 | // 1.0 是正常速度,对应 rate=0 1205 | rate = Math.round((requestData.speed - 1.0) * 100); 1206 | // 限制范围 1207 | rate = Math.max(-100, Math.min(100, rate)); 1208 | } 1209 | 1210 | // 设置输出格式 1211 | const outputFormat = requestData.response_format === 'opus' ? 1212 | 'audio-48khz-192kbitrate-mono-opus' : 1213 | 'audio-24khz-48kbitrate-mono-mp3'; 1214 | 1215 | // 调用 TTS API 1216 | const ttsResponse = await getVoice(text, voiceName, rate, 0,requestData.model ,outputFormat, false); 1217 | 1218 | return ttsResponse; 1219 | } catch (error) { 1220 | console.error('OpenAI TTS API error:', error); 1221 | return new Response(JSON.stringify({ error: 'Internal server error: ' + error.message }), { 1222 | status: 500, 1223 | headers: { 'Content-Type': 'application/json' } 1224 | }); 1225 | } 1226 | } --------------------------------------------------------------------------------
输入文本并选择语音进行转换
显示默认语音列表。请检查网络连接或稍后再试。
/tts?api_key={key}&t={text}&v={voice}&r={rate}&p={pitch}&s={style}
/v1/audio/speech 或 /audio/speech
使用方式与 OpenAI TTS API 相同,支持以下参数:
示例请求:
370 | curl ${baseUrl}/v1/audio/speech \\ 371 | -H "Authorization: Bearer your-secret-api-key" \\ 372 | -H "Content-Type: application/json" \\ 373 | -d '{ 374 | "model": "tts-1", 375 | "input": "这是一个语音合成测试", 376 | "voice": "alloy" 377 | }' 378 |
/voices?l={locale}&f={format}
420 | Microsoft TTS API 是一个高质量的文本转语音服务,支持多种语言和声音。 421 | 通过简单的 API 调用,可以将文本转换为自然流畅的语音。 422 |
433 | 支持 SSML 标签和 OpenAI 兼容接口 434 |
|<\/p>/g }, 20 | { name: 's', pattern: /|<\/s>/g }, 21 | { name: 'sub', pattern: /]*>|<\/sub>/g }, 22 | { name: 'mstts', pattern: /]*>|<\/mstts:[^>]*>/g } 23 | ]; 24 | 25 | function uuid(){ 26 | return crypto.randomUUID().replace(/-/g, '') 27 | } 28 | 29 | // EscapeSSML 转义 SSML 内容,但保留配置的标签 30 | function escapeSSML(ssml) { 31 | // 使用占位符替换标签 32 | let placeholders = new Map(); 33 | let processedSSML = ssml; 34 | let counter = 0; 35 | 36 | // 处理所有配置的标签 37 | for (const tag of preserveTags) { 38 | processedSSML = processedSSML.replace(tag.pattern, function(match) { 39 | const placeholder = `__SSML_PLACEHOLDER_${tag.name}_${counter++}__`; 40 | placeholders.set(placeholder, match); 41 | return placeholder; 42 | }); 43 | } 44 | 45 | // 对处理后的文本进行HTML转义 46 | let escapedContent = escapeBasicXml(processedSSML); 47 | 48 | // 恢复所有标签占位符 49 | placeholders.forEach((tag, placeholder) => { 50 | escapedContent = escapedContent.replace(placeholder, tag); 51 | }); 52 | 53 | return escapedContent; 54 | } 55 | 56 | // 基本 XML 转义功能,只处理基本字符 57 | function escapeBasicXml(unsafe) { 58 | return unsafe.replace(/[<>&'"]/g, function (c) { 59 | switch (c) { 60 | case '<': return '<'; 61 | case '>': return '>'; 62 | case '&': return '&'; 63 | case '\'': return '''; 64 | case '"': return '"'; 65 | } 66 | }); 67 | } 68 | 69 | async function handleRequest(request) { 70 | const requestUrl = new URL(request.url); 71 | const path = requestUrl.pathname; 72 | 73 | if (path === '/tts') { 74 | // 从请求参数获取 API 密钥 75 | const apiKey = requestUrl.searchParams.get('api_key'); 76 | 77 | // 验证 API 密钥 78 | if (!validateApiKey(apiKey)) { 79 | // 改进 401 错误响应,提供更友好的错误信息 80 | return new Response(JSON.stringify({ 81 | error: 'Unauthorized', 82 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 83 | status: 401 84 | }), { 85 | status: 401, 86 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 87 | }); 88 | } 89 | 90 | const text = requestUrl.searchParams.get('t') || ''; 91 | const voiceName = requestUrl.searchParams.get('v') || 'zh-CN-XiaoxiaoMultilingualNeural'; 92 | const rate = Number(requestUrl.searchParams.get('r')) || 0; 93 | const pitch = Number(requestUrl.searchParams.get('p')) || 0; 94 | const style = requestUrl.searchParams.get('s') || 'general'; 95 | const outputFormat = requestUrl.searchParams.get('o') || 'audio-24khz-48kbitrate-mono-mp3'; 96 | const download = requestUrl.searchParams.get('d') || false; 97 | const response = await getVoice(text, voiceName, rate, pitch, style, outputFormat, download); 98 | return response; 99 | } 100 | 101 | // 添加 reader.json 路径处理 102 | if (path === '/reader.json') { 103 | // 从请求参数获取 API 密钥 104 | const apiKey = requestUrl.searchParams.get('api_key'); 105 | 106 | // 验证 API 密钥 107 | if (!validateApiKey(apiKey)) { 108 | return new Response(JSON.stringify({ 109 | error: 'Unauthorized', 110 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 111 | status: 401 112 | }), { 113 | status: 401, 114 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 115 | }); 116 | } 117 | 118 | // 从URL参数获取 119 | const voice = requestUrl.searchParams.get('v') || ''; 120 | const rate = requestUrl.searchParams.get('r') || ''; 121 | const pitch = requestUrl.searchParams.get('p') || ''; 122 | const style = requestUrl.searchParams.get('s') || ''; 123 | const displayName = requestUrl.searchParams.get('n') || 'Microsoft TTS'; 124 | 125 | // 构建基本URL 126 | const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`; 127 | 128 | // 构建URL参数 129 | const urlParams = ["t={{java.encodeURI(speakText)}}", "r={{speakSpeed*4}}"]; 130 | 131 | // 只有有值的参数才添加 132 | if (voice) { 133 | urlParams.push(`v=${voice}`); 134 | } 135 | 136 | if (pitch) { 137 | urlParams.push(`p=${pitch}`); 138 | } 139 | 140 | if (style) { 141 | urlParams.push(`s=${style}`); 142 | } 143 | 144 | if (apiKey) { 145 | urlParams.push(`api_key=${apiKey}`); 146 | } 147 | 148 | const url = `${baseUrl}/tts?${urlParams.join('&')}`; 149 | 150 | // 返回 reader 响应 151 | return new Response(JSON.stringify({ 152 | id: Date.now(), 153 | name: displayName, 154 | url: url 155 | }), { 156 | status: 200, 157 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 158 | }); 159 | } 160 | 161 | // 添加 ifreetime.json 路径处理 162 | if (path === '/ifreetime.json') { 163 | // 从请求参数获取 API 密钥 164 | const apiKey = requestUrl.searchParams.get('api_key'); 165 | 166 | // 验证 API 密钥 167 | if (!validateApiKey(apiKey)) { 168 | return new Response(JSON.stringify({ 169 | error: 'Unauthorized', 170 | message: '无效的 API 密钥,请确保您提供了正确的密钥。', 171 | status: 401 172 | }), { 173 | status: 401, 174 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 175 | }); 176 | } 177 | 178 | // 从URL参数获取 179 | const voice = requestUrl.searchParams.get('v') || ''; 180 | const rate = requestUrl.searchParams.get('r') || ''; 181 | const pitch = requestUrl.searchParams.get('p') || ''; 182 | const style = requestUrl.searchParams.get('s') || ''; 183 | const displayName = requestUrl.searchParams.get('n') || 'Microsoft TTS'; 184 | 185 | // 构建基本URL 186 | const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`; 187 | const url = `${baseUrl}/tts`; 188 | 189 | // 生成随机的唯一ID 190 | const ttsConfigID = crypto.randomUUID(); 191 | 192 | // 构建请求参数 193 | const params = { 194 | "t": "%@", // %@ 是 IFreeTime 中的文本占位符 195 | "v": voice, 196 | "r": rate, 197 | "p": pitch, 198 | "s": style 199 | }; 200 | 201 | // 如果需要API密钥认证,添加到请求参数 202 | if (apiKey) { 203 | params["api_key"] = apiKey; 204 | } 205 | 206 | // 构建响应 207 | const response = { 208 | loginUrl: "", 209 | maxWordCount: "", 210 | customRules: {}, 211 | ttsConfigGroup: "Azure", 212 | _TTSName: displayName, 213 | _ClassName: "JxdAdvCustomTTS", 214 | _TTSConfigID: ttsConfigID, 215 | httpConfigs: { 216 | useCookies: 1, 217 | headers: {} 218 | }, 219 | voiceList: [], 220 | ttsHandles: [ 221 | { 222 | paramsEx: "", 223 | processType: 1, 224 | maxPageCount: 1, 225 | nextPageMethod: 1, 226 | method: 1, 227 | requestByWebView: 0, 228 | parser: {}, 229 | nextPageParams: {}, 230 | url: url, 231 | params: params, 232 | httpConfigs: { 233 | useCookies: 1, 234 | headers: {} 235 | } 236 | } 237 | ] 238 | }; 239 | 240 | // 返回 IFreeTime 响应 241 | return new Response(JSON.stringify(response), { 242 | status: 200, 243 | headers: { 'Content-Type': 'application/json; charset=utf-8' } 244 | }); 245 | } 246 | 247 | // 添加 OpenAI 兼容接口路由 248 | if (path === '/v1/audio/speech' || path === '/audio/speech') { 249 | return await handleOpenAITTS(request); 250 | } 251 | 252 | if(path === '/voices') { 253 | const l = (requestUrl.searchParams.get('l') || '').toLowerCase(); 254 | const f = requestUrl.searchParams.get('f'); 255 | let response = await voiceList(); 256 | 257 | if(l.length > 0) { 258 | response = response.filter(item => item.Locale.toLowerCase().includes(l)); 259 | } 260 | 261 | return new Response(JSON.stringify(response), { 262 | headers:{ 263 | 'Content-Type': 'application/json; charset=utf-8' 264 | } 265 | }); 266 | } 267 | 268 | const baseUrl = request.url.split('://')[0] + "://" +requestUrl.host; 269 | return new Response(` 270 | 271 | 272 | 273 | 274 | 275 | Microsoft TTS API 276 | 277 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | Microsoft TTS API 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 在线文本转语音 313 | 输入文本并选择语音进行转换 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 错误 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | API Key 331 | 332 | 333 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 保存 345 | 346 | 347 | 348 | 349 | API Key 已保存 350 | 351 | 352 | 编辑 353 | 354 | 355 | 356 | 357 | 358 | 输入文本 359 | 362 | 363 | 364 | 365 | 366 | 选择语音 367 | 369 | 370 | 371 | 372 | 373 | 语音风格 374 | 376 | 标准 377 | 广告热情 378 | 亲切 379 | 愤怒 380 | 助理 381 | 平静 382 | 随意 383 | 愉快 384 | 客服 385 | 沮丧 386 | 不满 387 | 纪录片解说 388 | 尴尬 389 | 共情 390 | 羡慕 391 | 兴奋 392 | 恐惧 393 | 友好 394 | 温柔 395 | 希望 396 | 抒情 397 | 专业叙述 398 | 轻松叙述 399 | 新闻播报 400 | 随意新闻 401 | 正式新闻 402 | 诗朗诵 403 | 悲伤 404 | 严肃 405 | 大喊 406 | 体育解说 407 | 激动体育解说 408 | 低语 409 | 恐慌 410 | 冷漠 411 | 412 | 413 | 414 | 415 | 416 | 417 | 语速调整 418 | 419 | 0 420 | 422 | 423 | 424 | 425 | 426 | 音调调整 427 | 428 | 0 429 | 431 | 432 | 433 | 434 | 435 | 436 | 438 | 生成语音 439 | 440 | 442 | 下载音频 443 | 444 | 446 | 导入阅读 447 | 448 | 450 | 导入爱阅记 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 无法加载语音列表 462 | 463 | 显示默认语音列表。请检查网络连接或稍后再试。 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 494 | 495 | 974 | 975 | 976 | `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8'}}); 977 | } 978 | 979 | addEventListener('fetch', event => { 980 | event.respondWith(handleRequest(event.request)); 981 | }); 982 | 983 | 984 | async function getEndpoint() { 985 | const endpointUrl = 'https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0'; 986 | const headers = { 987 | 'Accept-Language': 'zh-Hans', 988 | 'X-ClientVersion': '4.0.530a 5fe1dc6c', 989 | 'X-UserId': generateUserId(), // 使用随机生成的UserId 990 | 'X-HomeGeographicRegion': 'zh-Hans-CN', 991 | 'X-ClientTraceId': uuid(), // 直接使用uuid函数生成 992 | 993 | 'X-MT-Signature': await sign(endpointUrl), 994 | 'User-Agent': 'okhttp/4.5.0', 995 | 'Content-Type': 'application/json', 996 | 'Content-Length': '0', 997 | 'Accept-Encoding': 'gzip' 998 | }; 999 | 1000 | return fetch(endpointUrl, { 1001 | method: 'POST', 1002 | headers: headers 1003 | }).then(res => res.json()); 1004 | } 1005 | 1006 | // 随机生成 X-UserId,格式为 16 位字符(字母+数字) 1007 | function generateUserId() { 1008 | const chars = 'abcdef0123456789'; // 只使用16进制字符,与原格式一致 1009 | let result = ''; 1010 | for (let i = 0; i < 16; i++) { 1011 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 1012 | } 1013 | return result; 1014 | } 1015 | 1016 | async function sign(urlStr) { 1017 | const url = urlStr.split('://')[1]; 1018 | const encodedUrl = encodeURIComponent(url); 1019 | const uuidStr = uuid(); 1020 | const formattedDate = dateFormat(); 1021 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 1022 | const decode = await base64ToBytes('oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=='); 1023 | const signData = await hmacSha256(decode, bytesToSign); 1024 | const signBase64 = await bytesToBase64(signData); 1025 | return `MSTranslatorAndroidApp::${signBase64}::${formattedDate}::${uuidStr}`; 1026 | } 1027 | 1028 | function dateFormat() { 1029 | const formattedDate = new Date().toUTCString().replace(/GMT/, '').trim() + 'GMT'; 1030 | return formattedDate.toLowerCase(); 1031 | } 1032 | 1033 | function getSsml(text, voiceName, rate, pitch, style = 'general') { 1034 | text = escapeSSML(text); 1035 | return ` ${text} `; 1036 | } 1037 | 1038 | function voiceList() { 1039 | // 检查缓存是否有效 1040 | if (voiceListCache && voiceListCacheTime && (Date.now() - voiceListCacheTime) < VOICE_CACHE_DURATION) { 1041 | console.log('使用缓存的语音列表数据,剩余有效���:', 1042 | Math.round((VOICE_CACHE_DURATION - (Date.now() - voiceListCacheTime)) / 60000), '分钟'); 1043 | return Promise.resolve(voiceListCache); 1044 | } 1045 | 1046 | console.log('获取新的语音列表数据'); 1047 | const headers = { 1048 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.26', 1049 | 'X-Ms-Useragent': 'SpeechStudio/2021.05.001', 1050 | 'Content-Type': 'application/json', 1051 | 'Origin': 'https://azure.microsoft.com', 1052 | 'Referer': 'https://azure.microsoft.com' 1053 | }; 1054 | 1055 | return fetch('https://eastus.api.speech.microsoft.com/cognitiveservices/voices/list', { 1056 | headers: headers, 1057 | }) 1058 | .then(res => res.json()) 1059 | .then(data => { 1060 | // 更新缓存 1061 | voiceListCache = data; 1062 | voiceListCacheTime = Date.now(); 1063 | return data; 1064 | }); 1065 | } 1066 | 1067 | async function hmacSha256(key, data) { 1068 | const cryptoKey = await crypto.subtle.importKey( 1069 | 'raw', 1070 | key, 1071 | { name: 'HMAC', hash: { name: 'SHA-256' } }, 1072 | false, 1073 | ['sign'] 1074 | ); 1075 | const signature = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data)); 1076 | return new Uint8Array(signature); 1077 | } 1078 | 1079 | async function base64ToBytes(base64) { 1080 | const binaryString = atob(base64); 1081 | const bytes = new Uint8Array(binaryString.length); 1082 | for (let i = 0; i < binaryString.length; i++) { 1083 | bytes[i] = binaryString.charCodeAt(i); 1084 | } 1085 | return bytes; 1086 | } 1087 | 1088 | async function bytesToBase64(bytes) { 1089 | const base64 = btoa(String.fromCharCode.apply(null, bytes)); 1090 | return base64; 1091 | } 1092 | 1093 | 1094 | 1095 | // API 密钥验证函数 1096 | function validateApiKey(apiKey) { 1097 | // 从环境变量获取 API 密钥并进行验证 1098 | const expectedApiKey = API_KEY || ''; 1099 | return apiKey === expectedApiKey; 1100 | } 1101 | 1102 | async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, style = 'general', outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { 1103 | // get expiredAt from endpoint.t (jwt token) 1104 | if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { 1105 | endpoint = await getEndpoint(); 1106 | const jwt = endpoint.t.split('.')[1]; 1107 | const decodedJwt = JSON.parse(atob(jwt)); 1108 | expiredAt = decodedJwt.exp; 1109 | const seconds = (expiredAt - Date.now() / 1000); 1110 | clientId = uuid(); 1111 | console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') 1112 | } else { 1113 | const seconds = (expiredAt - Date.now() / 1000); 1114 | console.log('expiredAt:' + (seconds/ 60) + 'm left') 1115 | } 1116 | 1117 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 1118 | const headers = { 1119 | 'Authorization': endpoint.t, 1120 | 'Content-Type': 'application/ssml+xml', 1121 | 'User-Agent': 'okhttp/4.5.0', 1122 | 'X-Microsoft-OutputFormat': outputFormat 1123 | }; 1124 | const ssml = getSsml(text, voiceName, rate, pitch, style); 1125 | 1126 | const response = await fetch(url, { 1127 | method: 'POST', 1128 | headers: headers, 1129 | body: ssml 1130 | }); 1131 | if(response.ok) { 1132 | if (!download) { 1133 | return response; 1134 | } 1135 | const resp = new Response(response.body, response); 1136 | resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); 1137 | return resp; 1138 | } else { 1139 | return new Response(response.statusText, { status: response.status }); 1140 | } 1141 | } 1142 | 1143 | // 处理 OpenAI 格式的文本转语音请求 1144 | async function handleOpenAITTS(request) { 1145 | // 验证请求方法是否为 POST 1146 | if (request.method !== 'POST') { 1147 | return new Response(JSON.stringify({ error: 'Method not allowed' }), { 1148 | status: 405, 1149 | headers: { 'Content-Type': 'application/json' } 1150 | }); 1151 | } 1152 | 1153 | // 验证 API 密钥 1154 | const authHeader = request.headers.get('Authorization'); 1155 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 1156 | return new Response(JSON.stringify({ error: 'Unauthorized: Missing or invalid API key' }), { 1157 | status: 401, 1158 | headers: { 'Content-Type': 'application/json' } 1159 | }); 1160 | } 1161 | 1162 | const apiKey = authHeader.replace('Bearer ', ''); 1163 | if (!validateApiKey(apiKey)) { 1164 | return new Response(JSON.stringify({ error: 'Unauthorized: Invalid API key' }), { 1165 | status: 401, 1166 | headers: { 'Content-Type': 'application/json' } 1167 | }); 1168 | } 1169 | 1170 | try { 1171 | // 解析请求体 JSON 1172 | const requestData = await request.json(); 1173 | 1174 | // 验证必要参数 1175 | if (!requestData.model || !requestData.input) { 1176 | return new Response(JSON.stringify({ error: 'Bad request: Missing required parameters' }), { 1177 | status: 400, 1178 | headers: { 'Content-Type': 'application/json' } 1179 | }); 1180 | } 1181 | 1182 | // 提取参数 1183 | const text = requestData.input; 1184 | // 映射 voice 参数 (可选择添加 model 到 voice 的映射逻辑) 1185 | let voiceName = 'zh-CN-XiaoxiaoMultilingualNeural'; // 默认声音 1186 | if (requestData.voice) { 1187 | // OpenAI的voice参数有alloy, echo, fable, onyx, nova, shimmer 1188 | // 可以根据需要进行映射 1189 | const voiceMap = { 1190 | 'alloy': 'zh-CN-XiaoxiaoMultilingualNeural', 1191 | 'echo': 'zh-CN-YunxiNeural', 1192 | 'fable': 'zh-CN-XiaomoNeural', 1193 | 'onyx': 'zh-CN-YunjianNeural', 1194 | 'nova': 'zh-CN-XiaochenNeural', 1195 | 'shimmer': 'en-US-AriaNeural' 1196 | }; 1197 | voiceName = voiceMap[requestData.voice] || requestData.voice; 1198 | } 1199 | 1200 | // 速度和音调映射 (OpenAI 使用 0.25-4.0,我们使用 -100 到 100) 1201 | let rate = 0; 1202 | if (requestData.speed) { 1203 | // 映射 0.25-4.0 到 -100 到 100 范围 1204 | // 1.0 是正常速度,对应 rate=0 1205 | rate = Math.round((requestData.speed - 1.0) * 100); 1206 | // 限制范围 1207 | rate = Math.max(-100, Math.min(100, rate)); 1208 | } 1209 | 1210 | // 设置输出格式 1211 | const outputFormat = requestData.response_format === 'opus' ? 1212 | 'audio-48khz-192kbitrate-mono-opus' : 1213 | 'audio-24khz-48kbitrate-mono-mp3'; 1214 | 1215 | // 调用 TTS API 1216 | const ttsResponse = await getVoice(text, voiceName, rate, 0,requestData.model ,outputFormat, false); 1217 | 1218 | return ttsResponse; 1219 | } catch (error) { 1220 | console.error('OpenAI TTS API error:', error); 1221 | return new Response(JSON.stringify({ error: 'Internal server error: ' + error.message }), { 1222 | status: 500, 1223 | headers: { 'Content-Type': 'application/json' } 1224 | }); 1225 | } 1226 | } --------------------------------------------------------------------------------