From 3e0b9c1b7f1e5a7f86cf6e7aeb36ef8c48d5d0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=94=A6=E5=BC=BA?= <1061669148@qq.com> Date: Tue, 11 Mar 2025 13:59:40 +0800 Subject: [PATCH] feat: enhance TTS API with SSML support and improved UI for voice selection --- workers/src/index.js | 645 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 535 insertions(+), 110 deletions(-) diff --git a/workers/src/index.js b/workers/src/index.js index 67e5cc2..d154af4 100644 --- a/workers/src/index.js +++ b/workers/src/index.js @@ -1,9 +1,68 @@ const encoder = new TextEncoder(); let expiredAt = null; let endpoint = null; -let clientId = '76a75279-2ffa-4c3d-8db8-7b47252aa41c'; const API_KEY = 'your-secret-api-key'; // 添加 API 密钥常量,可以修改为你想要的值 +// 定义需要保留的 SSML 标签模式 +const preserveTags = [ + { name: 'break', pattern: /]*\/>/g }, + { name: 'speak', pattern: /|<\/speak>/g }, + { name: 'prosody', pattern: /]*>|<\/prosody>/g }, + { name: 'emphasis', pattern: /]*>|<\/emphasis>/g }, + { name: 'voice', pattern: /]*>|<\/voice>/g }, + { name: 'say-as', pattern: /]*>|<\/say-as>/g }, + { name: 'phoneme', pattern: /]*>|<\/phoneme>/g }, + { name: 'audio', pattern: /]*>|<\/audio>/g }, + { name: 'p', pattern: /

|<\/p>/g }, + { name: 's', pattern: /|<\/s>/g }, + { name: 'sub', pattern: /]*>|<\/sub>/g }, + { name: 'mstts', pattern: /]*>|<\/mstts:[^>]*>/g } +]; + +function uuid(){ + return crypto.randomUUID().replace(/-/g, '') +} + +// EscapeSSML 转义 SSML 内容,但保留配置的标签 +function escapeSSML(ssml) { + // 使用占位符替换标签 + let placeholders = new Map(); + let processedSSML = ssml; + let counter = 0; + + // 处理所有配置的标签 + for (const tag of preserveTags) { + processedSSML = processedSSML.replace(tag.pattern, function(match) { + const placeholder = `__SSML_PLACEHOLDER_${tag.name}_${counter++}__`; + placeholders.set(placeholder, match); + return placeholder; + }); + } + + // 对处理后的文本进行HTML转义 + let escapedContent = escapeBasicXml(processedSSML); + + // 恢复所有标签占位符 + placeholders.forEach((tag, placeholder) => { + escapedContent = escapedContent.replace(placeholder, tag); + }); + + return escapedContent; +} + +// 基本 XML 转义功能,只处理基本字符 +function escapeBasicXml(unsafe) { + return unsafe.replace(/[<>&'"]/g, function (c) { + switch (c) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '\'': return '''; + case '"': return '"'; + } + }); +} + async function handleRequest(request) { const requestUrl = new URL(request.url); const path = requestUrl.pathname; @@ -74,66 +133,437 @@ async function handleRequest(request) { const baseUrl = request.url.split('://')[0] + "://" +requestUrl.host; return new Response(` - + + + + Microsoft TTS API - + + - -

Microsoft TTS API 接口说明

- -
-

1. 文本转语音 API

-

/tts - 将文本转换为语音

- -
-

api_key [必填]: API访问密钥

-

t [必填]: 要转换的文本内容

-

v [可选]: 语音名称,默认为 'zh-CN-XiaoxiaoMultilingualNeural'

-

r [可选]: 语速调整,范围-100到100,默认为0

-

p [可选]: 音调调整,范围-100到100,默认为0

-

o [可选]: 输出格式,默认为 'audio-24khz-48kbitrate-mono-mp3'

-

d [可选]: 是否作为下载文件返回,设为任意值时启用

+ + + -
-

示例: 尝试

- ${baseUrl}/tts?api_key=api-key&t=你好,世界&v=zh-CN-XiaoxiaoMultilingualNeural&r=0&p=0 + +
+ +
+ +
+
+
+

在线文本转语音

+

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

+
+
+
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+ 0 + +
+
+ +
+ +
+ 0 + +
+
+
+ +
+ + +
+ + +
+ + +
+
+
+ + +
+ +
+
+

关于服务

+
+

+ Microsoft TTS API 是一个高质量的文本转语音服务,支持多种语言和声音。 + 通过简单的 API 调用,可以将文本转换为自然流畅的语音。 +

+
+
+
+
+ + + +
+
+

+ 支持 SSML 标签,可以精确控制语音合成效果 +

+
+
+
+
+
+ + +
+
+

API 文档

+
+
+

文本转语音 API

+ /tts?api_key={key}&t={text}&v={voice}&r={rate}&p={pitch} +
    +
  • api_key: API密钥 [必填]
  • +
  • t: 文本内容 [必填]
  • +
  • v: 语音名称 [可选]
  • +
  • r: 语速调整 (-100~100) [可选]
  • +
  • p: 音调调整 (-100~100) [可选]
  • +
+ +

获取语音列表 API

+ /voices?l={locale}&f={format} +
    +
  • l: 语言筛选 (如 'zh', 'en')
  • +
  • f: 返回格式 (0=TTS格式, 1=JSON格式)
  • +
+ +
+
+
+ + + +
+
+

重要提示

+
+
    +
  • 所有请求必须提供有效的 API 密钥
  • +
  • 请确保中文文本进行 URL 编码
  • +
+
+
+
+
+
+
+
-
+ -
-

2. 获取可用语音列表

-

/voices - 获取所有可用的语音列表

- -
-

l [可选]: 按区域筛选,如 'zh'、'zh-CN'、'en' 等

-

f [可选]: 返回格式,0=TTS-Server格式,1=MultiTTS格式,默认=完整JSON

+ +
+
+

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

+
-
-

示例 (中文语音): 尝试

- ${baseUrl}/voices?l=zh -
-
+ `, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }}); @@ -148,9 +578,9 @@ async function getEndpoint() { const headers = { 'Accept-Language': 'zh-Hans', 'X-ClientVersion': '4.0.530a 5fe1dc6c', - 'X-UserId': '0f04d16a175c411e', + 'X-UserId': generateUserId(), // 使用随机生成的UserId 'X-HomeGeographicRegion': 'zh-Hans-CN', - 'X-ClientTraceId': clientId, + 'X-ClientTraceId': uuid(), // 直接使用uuid函数生成 'X-MT-Signature': await sign(endpointUrl), 'User-Agent': 'okhttp/4.5.0', @@ -165,6 +595,16 @@ async function getEndpoint() { }).then(res => res.json()); } +// 随机生成 X-UserId,格式为 16 位字符(字母+数字) +function generateUserId() { + const chars = 'abcdef0123456789'; // 只使用16进制字符,与原格式一致 + let result = ''; + for (let i = 0; i < 16; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + async function sign(urlStr) { const url = urlStr.split('://')[1]; const encodedUrl = encodeURIComponent(url); @@ -182,61 +622,8 @@ function dateFormat() { return formattedDate.toLowerCase(); } -async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { - // get expiredAt from endpoint.t (jwt token) - if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { - endpoint = await getEndpoint(); - const jwt = endpoint.t.split('.')[1]; - const decodedJwt = JSON.parse(atob(jwt)); - expiredAt = decodedJwt.exp; - const seconds = (expiredAt - Date.now() / 1000); - clientId = uuid(); - console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') - } else { - const seconds = (expiredAt - Date.now() / 1000); - console.log('expiredAt:' + (seconds/ 60) + 'm left') - } - - const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; - const headers = { - 'Authorization': endpoint.t, - 'Content-Type': 'application/ssml+xml', - 'User-Agent': 'okhttp/4.5.0', - 'X-Microsoft-OutputFormat': outputFormat - }; - const ssml = getSsml(text, voiceName, rate, pitch); - - const response = await fetch(url, { - method: 'POST', - headers: headers, - body: ssml - }); - if(response.ok) { - if (!download) { - return response; - } - resp = new Response(response.body,response) - resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); - return resp; - }else { - return new Response(response.statusText, { status: response.status }); - } -} - -function escapeXml(unsafe) { - return unsafe.replace(/[<>&'"]/g, function (c) { - switch (c) { - case '<': return '<'; - case '>': return '>'; - case '&': return '&'; - case '\'': return '''; - case '"': return '"'; - } - }); -} - function getSsml(text, voiceName, rate, pitch) { - text = escapeXml(text); + text = escapeSSML(text); return ` ${text} `; } @@ -288,11 +675,49 @@ async function bytesToBase64(bytes) { } -function uuid(){ - return crypto.randomUUID().replace(/-/g, '') -} // API 密钥验证函数 function validateApiKey(apiKey) { return apiKey === API_KEY; +} + +async function getVoice(text, voiceName = 'zh-CN-XiaoxiaoMultilingualNeural', rate = 0, pitch = 0, outputFormat='audio-24khz-48kbitrate-mono-mp3', download=false) { + // get expiredAt from endpoint.t (jwt token) + if (!expiredAt || Date.now() / 1000 > expiredAt - 60) { + endpoint = await getEndpoint(); + const jwt = endpoint.t.split('.')[1]; + const decodedJwt = JSON.parse(atob(jwt)); + expiredAt = decodedJwt.exp; + const seconds = (expiredAt - Date.now() / 1000); + clientId = uuid(); + console.log('getEndpoint, expiredAt:' + (seconds/ 60) + 'm left') + } else { + const seconds = (expiredAt - Date.now() / 1000); + console.log('expiredAt:' + (seconds/ 60) + 'm left') + } + + const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; + const headers = { + 'Authorization': endpoint.t, + 'Content-Type': 'application/ssml+xml', + 'User-Agent': 'okhttp/4.5.0', + 'X-Microsoft-OutputFormat': outputFormat + }; + const ssml = getSsml(text, voiceName, rate, pitch); + + const response = await fetch(url, { + method: 'POST', + headers: headers, + body: ssml + }); + if(response.ok) { + if (!download) { + return response; + } + const resp = new Response(response.body, response); + resp.headers.set('Content-Disposition', `attachment; filename="${uuid()}.mp3"`); + return resp; + } else { + return new Response(response.statusText, { status: response.status }); + } } \ No newline at end of file