├── 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 | ![ms-api-key-setting.png](ms-api-key-setting.png) 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 | ![Gemini-api-key-setting.png](Gemini-api-key-setting.png) 28 | ![OpenAI-api-key-setting.png](OpenAI-api-key-setting.png) 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 | 4 | 5 | Gemini TTS Client 6 | 7 | 246 | 247 | 248 |
249 |

Gemini TTS Client

250 | 251 | 252 | 253 |
254 |
255 |

配置

256 | 257 | 258 | 259 |
API基础地址,到 /v1beta 为止
260 | 261 | 262 | 266 |
Flash 模型速度更快,Pro 模型质量更高
267 | 268 | 269 | 270 | 271 | 272 | 304 |
305 | 306 |
307 |

💡 控制语音的小技巧

308 |
    309 |
  • 速度:使用 "Say quickly:" 或 "Say slowly:" 等提示词
  • 310 |
  • 语气:如 "Say cheerfully:", "Say in a whisper:", "Say dramatically:"
  • 311 |
  • 情感:如 "Say excitedly:", "Say calmly:", "Say mysteriously:"
  • 312 |
  • 组合使用:"Say slowly and dramatically: ..."
  • 313 |
314 |
315 | 316 |
317 | 318 | 319 |
320 |
321 | 322 |
323 | 324 |
325 |
326 |
327 | 330 | 331 |
332 | 333 |
334 |
335 | 336 | 579 | 580 | -------------------------------------------------------------------------------- /Microsoft Azure TTS-V1.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 | // 添加 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 |

158 | 159 | 160 |
161 |
162 | 167 |
168 | 169 |
170 | 171 |
172 | 173 |
174 |
175 |
176 |

在线文本转语音

177 |

输入文本并选择语音进行转换

178 |
179 |
180 |
181 | 182 | 192 | 193 |
194 | 195 |
196 |
197 | 200 | 206 |
207 | 210 |
211 | 219 |
220 | 221 |
222 | 223 | 226 |
227 | 228 |
229 |
230 | 231 | 234 |
235 | 236 |
237 | 238 | 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 | 304 | 308 |
309 | 310 | 325 |
326 | 327 | 330 |
331 |
332 |
333 |
334 |
335 | 336 | 410 | 411 | 442 |
443 | 444 | 445 |
446 |
447 |

© ${new Date().getFullYear()} Microsoft TTS API 服务

448 |
449 |
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 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 252 | 253 | 254 | 257 |
258 |
259 | 260 | 261 |
262 |
263 | 264 |
265 | Placeholder Image 266 |
267 | 268 |
269 | 270 |
271 |
272 |
273 | 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 |

304 | 305 | 306 |
307 |
308 | 309 |
310 |
311 |
312 |

在线文本转语音

313 |

输入文本并选择语音进行转换

314 |
315 |
316 |
317 | 318 | 328 | 329 |
330 | 331 |
332 |
333 | 336 | 342 |
343 | 346 |
347 | 355 |
356 | 357 |
358 | 359 | 362 |
363 | 364 |
365 |
366 | 367 | 370 |
371 | 372 |
373 | 374 | 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 | 440 | 444 | 448 | 452 |
453 | 454 | 468 |
469 | 470 | 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 | } --------------------------------------------------------------------------------