├── wrangler.toml ├── .gitignore ├── readme-dev.md ├── test_voices.sh ├── readme.md └── worker.js /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "tts-worker" 2 | main = "worker.js" 3 | compatibility_date = "2024-01-01" 4 | 5 | [vars] 6 | API_KEY = "abc" 7 | 8 | [dev] 9 | ip = "0.0.0.0" 10 | port = 8787 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 测试生成的文件 2 | test/ 3 | .next/ 4 | .wrangler/ 5 | 6 | # Node.js 相关 7 | node_modules/ 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | .npm 12 | .yarn 13 | 14 | # IDE 和编辑器 15 | .idea/ 16 | .vscode/ 17 | *.swp 18 | *.swo 19 | *~ 20 | .DS_Store 21 | 22 | # 环境变量和密钥 23 | .env 24 | .env.local 25 | .env.*.local 26 | 27 | # 日志文件 28 | *.log 29 | logs/ 30 | test_results.txt 31 | 32 | # 临时文件 33 | tmp/ 34 | temp/ 35 | .tmp 36 | .temp 37 | 38 | # 操作系统生成的文件 39 | .DS_Store 40 | Thumbs.db 41 | desktop.ini 42 | 43 | # 其他 44 | *.bak 45 | *.backup 46 | *.old 47 | *.tmp -------------------------------------------------------------------------------- /readme-dev.md: -------------------------------------------------------------------------------- 1 | # Edge TTS Worker 开发指南 2 | 3 | 本文档用于指导开发者如何在本地设置开发环境、运行测试和部署 Edge TTS Worker。 4 | 5 | ## 开发环境设置 6 | 7 | ### 前置要求 8 | 1. Node.js (建议 v16 或更高版本) 9 | 2. npm 或 yarn 10 | 3. Git 11 | 12 | ### 安装开发工具 13 | 1. 安装 Wrangler CLI: 14 | ```bash 15 | npm install -g wrangler 16 | ``` 17 | 18 | 2. 登录到 Cloudflare: 19 | ```bash 20 | wrangler login 21 | ``` 22 | 23 | ## 本地开发 24 | 25 | ### 1. 克隆项目 26 | ```bash 27 | git clone <项目地址> 28 | cd edge-tts-worker 29 | ``` 30 | 31 | ### 2. 配置开发环境 32 | 1. 创建 `wrangler.toml` 配置文件: 33 | ```toml 34 | name = "edge-tts-worker" 35 | main = "worker.js" 36 | compatibility_date = "2024-01-01" 37 | 38 | [vars] 39 | API_KEY = "abc" 40 | 41 | [dev] 42 | ip = "0.0.0.0" 43 | port = 8787 44 | ``` 45 | 46 | 2. 创建本地环境变量文件 `.dev.vars`: 47 | ```plaintext 48 | API_KEY=your-test-key 49 | ``` 50 | 51 | ### 3. 运行开发服务器 52 | ```bash 53 | wrangler dev 54 | ``` 55 | 服务器将在 http://localhost:8787 启动 56 | 57 | ### 本地测试 58 | 59 | #### 1. 使用 curl 测试 API 60 | 61 | 测试中文语音: 62 | ```bash 63 | curl -X POST http://localhost:8787/v1/audio/speech \ 64 | -H "Content-Type: application/json" \ 65 | -H "Authorization: Bearer abc" \ 66 | -d '{ 67 | "model": "tts-1", 68 | "input": "测试文本", 69 | "voice": "zh-CN-XiaoxiaoNeural" 70 | }' --output test.mp3 71 | ``` 72 | 73 | #### 2. 使用测试脚本 74 | 在本地开发环境中运行测试脚本: 75 | ```bash 76 | ./test_voices.sh http://localhost:8787 abc 77 | ``` 78 | 79 | ### 调试技巧 80 | 81 | 1. 查看日志: 82 | ```bash 83 | # 实时查看日志 84 | wrangler tail 85 | 86 | # 开发模式下日志会直接显示在终端 87 | ``` 88 | 89 | 2. 使用 console.log: 90 | ```javascript 91 | // worker.js 中添加日志 92 | console.log('请求参数:', request); 93 | ``` 94 | 95 | 3. 测试不同配置: 96 | - 修改 speed 和 pitch 参数 97 | - 测试不同的语音模型 98 | - 验证错误处理 99 | 100 | ## 部署流程 101 | 102 | ### 1. 测试构建 103 | ```bash 104 | wrangler publish --dry-run 105 | ``` 106 | 107 | ### 2. 部署到开发环境 108 | ```bash 109 | wrangler publish --env development 110 | ``` 111 | 112 | ### 3. 部署到生产环境 113 | ```bash 114 | wrangler deploy 115 | ``` 116 | 117 | ## 常见开发问题 118 | 119 | ### 1. CORS 问题 120 | 如果遇到跨域问题,检查 worker.js 中的 CORS 配置: 121 | ```javascript 122 | const corsHeaders = { 123 | "Access-Control-Allow-Origin": "*", 124 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 125 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 126 | }; 127 | ``` 128 | 129 | ### 2. 环境变量问题 130 | - 确保 `.dev.vars` 文件存在且格式正确 131 | - 检查 `wrangler.toml` 中的变量配置 132 | 133 | ### 3. 请求超时 134 | - Worker 有 30 秒的执行时间限制 135 | - 建议限制输入文本长度 136 | - 考虑添加超时处理 137 | 138 | 139 | ## 贡献指南 140 | 141 | 1. 提交 PR 前: 142 | - 确保代码已经过本地测试 143 | - 遵循项目的代码风格 144 | - 更新相关文档 145 | 146 | 2. 代码审查 147 | - 所有改动需要通过代码审查 148 | - 遵循项目的贡献指南 149 | 150 | ## 有用的开发资源 151 | 152 | - [Cloudflare Workers 文档](https://developers.cloudflare.com/workers/) 153 | - [Wrangler CLI 文档](https://developers.cloudflare.com/workers/wrangler/) 154 | - [Edge TTS API 文档](https://learn.microsoft.com/zh-cn/azure/cognitive-services/speech-service/rest-text-to-speech) 155 | -------------------------------------------------------------------------------- /test_voices.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 检查命令行参数 4 | if [ $# -lt 1 ]; then 5 | echo "使用方法: $0 [API_KEY]" 6 | echo "示例: $0 https://example.com api_key_123" 7 | exit 1 8 | fi 9 | 10 | # 从命令行参数获取配置 11 | WORKER_URL="$1" 12 | API_KEY="${2:-}" # 如果没有提供第二个参数,API_KEY 将为空 13 | 14 | # 验证 WORKER_URL 15 | if [[ ! "$WORKER_URL" =~ ^https?:// ]]; then 16 | echo "错误: WORKER_URL 必须以 http:// 或 https:// 开头" 17 | exit 1 18 | fi 19 | 20 | # 确保 URL 末尾没有斜杠,然后添加 API 路径 21 | WORKER_URL="${WORKER_URL%/}/v1/audio/speech" 22 | 23 | # 创建输出目录(使用绝对路径) 24 | OUTPUT_DIR="$(pwd)/test/voice_test_$(date +%Y%m%d_%H%M%S)" 25 | mkdir -p "$OUTPUT_DIR" 26 | 27 | # 要测试的语音列表 28 | VOICES=( 29 | "alloy" 30 | "echo" 31 | "fable" 32 | "onyx" 33 | "nova" 34 | "shimmer" 35 | "zh-CN-XiaoxiaoNeural" 36 | "zh-CN-XiaoyiNeural" 37 | "zh-CN-YunxiNeural" 38 | "zh-CN-YunyangNeural" 39 | "zh-CN-XiaohanNeural" 40 | "zh-CN-XiaomengNeural" 41 | "zh-CN-XiaochenNeural" 42 | "zh-CN-XiaoruiNeural" 43 | "zh-CN-XiaoshuangNeural" 44 | "zh-CN-YunfengNeural" 45 | "zh-CN-YunjianNeural" 46 | "zh-CN-XiaoxuanNeural" 47 | "zh-CN-YunxiaNeural" 48 | "zh-CN-XiaomoNeural" 49 | "zh-CN-XiaozhenNeural" 50 | "en-US-JennyNeural" 51 | "en-US-GuyNeural" 52 | "ja-JP-NanamiNeural" 53 | "ja-JP-KeitaNeural" 54 | "ko-KR-SunHiNeural" 55 | "ko-KR-InJoonNeural" 56 | ) 57 | 58 | # 测试文本 59 | TEXTS=( 60 | "你好世界" 61 | "Hello World" 62 | "こんにちは世界" 63 | "안녕하세요 세계" 64 | ) 65 | 66 | # 检查MP3文件是否有效 67 | check_mp3() { 68 | local file="$1" 69 | # 检查文件大小是否大于0 70 | if [ ! -s "$file" ]; then 71 | echo "文件大小为0" >&2 72 | return 1 73 | fi 74 | # 使用 file 命令检查文件类型 75 | if ! file "$file" | grep -qi "audio\|mpeg\|mp3"; then 76 | echo "不是有效的音频文件" >&2 77 | echo "文件类型:" >&2 78 | file "$file" >&2 79 | return 1 80 | fi 81 | return 0 82 | } 83 | 84 | echo "开始测试语音..." 85 | echo "输出目录: $OUTPUT_DIR" 86 | echo "----------------------------------------" 87 | 88 | # 构建curl命令的headers 89 | HEADERS=(-H "Content-Type: application/json") 90 | if [ ! -z "$API_KEY" ]; then 91 | HEADERS+=(-H "Authorization: Bearer $API_KEY") 92 | fi 93 | 94 | # 创建测试结果日志 95 | RESULT_LOG="$OUTPUT_DIR/test_results.txt" 96 | echo "语音测试结果 - $(date '+%Y年 %m月 %d日 %A %H:%M:%S %Z')" > "$RESULT_LOG" 97 | echo "----------------------------------------" >> "$RESULT_LOG" 98 | 99 | # 成功计数器 100 | success_count=0 101 | total_count=${#VOICES[@]} 102 | 103 | for voice in "${VOICES[@]}"; do 104 | echo "测试语音: $voice" 105 | echo "测试语音: $voice" >> "$RESULT_LOG" 106 | 107 | # 根据语音选择合适的测试文本 108 | text="${TEXTS[0]}" # 默认使用中文 109 | if [[ $voice == en-US-* ]]; then 110 | text="${TEXTS[1]}" # 英文 111 | elif [[ $voice == ja-JP-* ]]; then 112 | text="${TEXTS[2]}" # 日文 113 | elif [[ $voice == ko-KR-* ]]; then 114 | text="${TEXTS[3]}" # 韩文 115 | fi 116 | 117 | output_file="$OUTPUT_DIR/test_${voice}.mp3" 118 | 119 | # 发送请求并保存响应 120 | response=$(curl -s -w "\n%{http_code}" -X POST "$WORKER_URL" \ 121 | "${HEADERS[@]}" \ 122 | -d "{ 123 | \"model\": \"tts-1\", 124 | \"input\": \"$text\", 125 | \"voice\": \"$voice\", 126 | \"response_format\": \"mp3\", 127 | \"speed\": 1.0 128 | }" -o "$output_file") 129 | 130 | # 获取 HTTP 状态码 131 | http_code=$(echo "$response" | tail -n1) 132 | 133 | # 检查结果 134 | if [ "$http_code" == "200" ]; then 135 | if check_mp3 "$output_file"; then 136 | echo "✅ 成功 - 已保存到 $output_file" 137 | echo "✅ 成功" >> "$RESULT_LOG" 138 | ((success_count++)) 139 | else 140 | error_msg=$(check_mp3 "$output_file" 2>&1) 141 | echo "❌ 失败 - 文件验证失败: $error_msg" 142 | echo "❌ 失败 - 文件验证失败: $error_msg" >> "$RESULT_LOG" 143 | # 创建 errors 子目录 144 | mkdir -p "$OUTPUT_DIR/errors" 145 | # 移动到 errors 目录,保持原始文件名 146 | mv "$output_file" "$OUTPUT_DIR/errors/$(basename "$output_file")" 147 | echo "已保存错误响应到: $OUTPUT_DIR/errors/$(basename "$output_file")" 148 | fi 149 | else 150 | echo "❌ 失败 (HTTP $http_code)" 151 | echo "❌ 失败 (HTTP $http_code)" >> "$RESULT_LOG" 152 | rm -f "$output_file" # 删除失败的文件 153 | fi 154 | echo "----------------------------------------" | tee -a "$RESULT_LOG" 155 | done 156 | 157 | # 输出测试总结 158 | echo -e "\n测试完成!" 159 | echo -e "\n测试总结:" >> "$RESULT_LOG" 160 | echo "总计测试: $total_count" | tee -a "$RESULT_LOG" 161 | echo "成功数量: $success_count" | tee -a "$RESULT_LOG" 162 | echo "失败数量: $((total_count - success_count))" | tee -a "$RESULT_LOG" 163 | echo "成功率: $((success_count * 100 / total_count))%" | tee -a "$RESULT_LOG" 164 | 165 | # 列出成功生成的文件 166 | echo -e "\n成功生成的语音文件:" 167 | ls -lh "$OUTPUT_DIR"/*.mp3 2>/dev/null 168 | 169 | # 如果全部失败,给出提示 170 | if [ $success_count -eq 0 ]; then 171 | echo -e "\n⚠️ 警告:所有测试都失败了!" 172 | echo "请检查:" 173 | echo "1. Worker URL 是否正确" 174 | echo "2. API Key 是否正确(如果需要)" 175 | echo "3. 网络连接是否正常" 176 | fi -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Edge TTS Worker 2 | 3 | Edge TTS Worker 是一个部署在 Cloudflare Worker 上的代理服务,它将微软 Edge TTS 服务封装成兼容 OpenAI 格式的 API 接口。通过本项目,您可以在没有微软认证的情况下,轻松使用微软高质量的语音合成服务。 4 | 5 | ## 📑 目录 6 | 7 | - [✨ 特点](#-特点) 8 | - [🚀 快速部署](#-快速部署) 9 | - [🧪 测试脚本使用](#-测试脚本使用) 10 | - [🔧 API 使用说明](#-api-使用说明) 11 | - [📝 注意事项](#-注意事项) 12 | - [❓ 常见问题](#-常见问题) 13 | - [📄 许可证](#-许可证) 14 | 15 | ## ✨ 特点 16 | 17 | - 绕过大陆地区访问限制,免去微软服务认证步骤 18 | - 提供 OpenAI 兼容的接口格式 19 | - 完全免费 - 基于 Cloudflare Worker 免费计划 20 | - 安全可控 - 支持自定义 API 密钥 21 | - 多语种支持 - 中文、英文、日文、韩文等 22 | - 快速部署 - 几分钟内即可完成 23 | 24 | ## 🚀 快速部署 25 | 26 | ### 1. 创建 Worker 27 | 1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/) 28 | 2. 进入 `Workers & Pages` 29 | 3. 点击 `Create Worker` 30 | 4. 为 Worker 取个名字(比如 `edge-tts`) 31 | 32 | ### 2. 部署代码 33 | 1. 删除编辑器中的默认代码 34 | 2. 复制 [worker.js](worker.js) 中的代码并粘贴 35 | 3. 点击 `Save and deploy` 36 | 37 | ### 3. 设置 API Key(可选) 38 | 1. 在 Worker 的设置页面中找到 `Settings` -> `Variables` 39 | 2. 点击 `Add variable` 40 | 3. 名称填写 `API_KEY`,值填写你想要的密钥 41 | 4. 点击 `Save and deploy` 42 | 43 | ### 4. 配置自定义域名(可选) 44 | 45 | #### 前提条件 46 | - 你的域名已经托管在 Cloudflare 47 | - 域名的 DNS 记录已经通过 Cloudflare 代理(代理状态为橙色云朵) 48 | 49 | #### 配置步骤 50 | 1. 在 Worker 的详情页面中 51 | 2. 点击 `设置` 标签 52 | 3. 找到 `域和路由` 部分 53 | 4. 点击 `添加` 按钮 54 | 5. 选择 `自定义域` 55 | 6. 输入你想要使用的域名(比如 `tts.example.com`) 56 | 7. 点击 `添加域` 57 | 8. 等待证书部署完成(通常几分钟内) 58 | 59 | 完成后,你可以通过以下两种方式访问服务: 60 | - Workers 域名:`https://你的worker名字.你的用户名.workers.dev` 61 | - 自定义域名:`https://tts.example.com` 62 | 63 | > 注意:自定义域名必须使用 HTTPS,Cloudflare 会自动提供 SSL 证书。 64 | 65 | ## 🧪 测试脚本使用 66 | 67 | 为了方便测试不同的语音效果,项目提供了一个测试脚本。这个脚本会使用不同的语音来合成相同的文本,帮助你快速对比各种语音效果。 68 | 69 | ### 使用方法 70 | 71 | 1. 下载测试脚本 `test_voices.sh` 72 | 2. 给脚本添加执行权限: 73 | ```bash 74 | chmod +x test_voices.sh 75 | ``` 76 | 3. 运行脚本: 77 | ```bash 78 | ./test_voices.sh [API密钥] 79 | ``` 80 | 81 | 示例: 82 | ```bash 83 | # 使用 API 密钥 84 | ./test_voices.sh https://your-worker.workers.dev your-api-key 85 | 86 | # 不使用 API 密钥 87 | ./test_voices.sh https://your-worker.workers.dev 88 | ``` 89 | 90 | 脚本会为每个支持的语音生成测试音频文件,你可以通过播放这些文件来选择最适合的语音。 91 | 92 | ## 🔧 API 使用说明 93 | 94 | ### 基础用法 95 | 96 | **最简单的调用方式:** 97 | ```bash 98 | curl -X POST https://你的worker地址/v1/audio/speech \ 99 | -H "Content-Type: application/json" \ 100 | -H "Authorization: Bearer your-api-key" \ 101 | -d '{ 102 | "model": "tts-1", 103 | "input": "你好,世界!", 104 | "voice": "zh-CN-XiaoxiaoNeural" 105 | }' --output output.mp3 106 | ``` 107 | 108 | ### 高级功能 109 | 110 | **语音情绪控制** 111 | ```bash 112 | curl -X POST https://你的worker地址/v1/audio/speech \ 113 | -H "Content-Type: application/json" \ 114 | -H "Authorization: Bearer your-api-key" \ 115 | -d '{ 116 | "model": "tts-1", 117 | "input": "这是一段开心的话!", 118 | "voice": "zh-CN-XiaoxiaoNeural", 119 | "style": "cheerful", 120 | "speed": 1.2 121 | }' --output happy.mp3 122 | ``` 123 | 124 | 125 | ### 参数说明 126 | 127 | | 参数 | 类型 | 必填 | 说明 | 默认值 | 示例值 | 128 | |------|------|------|------|--------|--------| 129 | | model | string | 是 | 模型名称(固定值) | - | tts-1 | 130 | | input | string | 是 | 要转换的文本内容 | - | "你好,世界!" | 131 | | voice | string | 是 | 语音角色名称 | - | zh-CN-XiaoxiaoNeural | 132 | | response_format | string | 否 | 音频输出格式 | mp3 | mp3 | 133 | | speed | number | 否 | 语速调节 (0.5-2.0) | 1.0 | 1.2 | 134 | | pitch | number | 否 | 音调调节 (0.5-2.0) | 1.0 | 1.1 | 135 | | style | string | 否 | 语音情绪风格 | general | cheerful | 136 | 137 | ### 语音角色说明 138 | 139 | #### OpenAI 兼容语音映射 140 | 141 | 请根据实际需要,在worker.js中添加/修改对应的映射关系。 142 | 143 | | OpenAI 语音 | 对应微软语音角色 | 特点描述 | 144 | |------------|-----------------|----------| 145 | | alloy | zh-CN-XiaoxiaoNeural | 晓晓 - 温暖自然的女声 | 146 | | echo | zh-CN-YunxiNeural | 云希 - 稳重大气的男声 | 147 | | fable | zh-CN-XiaoyiNeural | 晓伊 - 亲切温柔的女声 | 148 | | onyx | zh-CN-YunyangNeural | 云扬 - 专业权威的男声 | 149 | | nova | zh-CN-XiaohanNeural | 晓涵 - 清新活泼的女声 | 150 | | shimmer | zh-CN-XiaomengNeural | 晓梦 - 甜美动人的女声 | 151 | 152 | ### 使用注意事项 153 | 154 | 1. **语音与文本匹配** 155 | - 中文语音(zh-CN)仅支持中文文本 156 | - 英文语音(en-US)仅支持英文文本 157 | - 日文语音(ja-JP)仅支持日文文本 158 | - 韩文语音(ko-KR)仅支持韩文文本 159 | 160 | 错误的语音文本匹配可能导致: 161 | - 发音不自然 162 | - 语音合成失败 163 | - API 返回错误 164 | - 音频质量下降 165 | 166 | 2. **多语种支持示例** 167 | ```javascript 168 | // 根据文本语言自动选择合适的语音 169 | function selectVoice(text) { 170 | if(/[\u4e00-\u9fa5]/.test(text)) { 171 | return 'zh-CN-XiaoxiaoNeural'; // 中文 172 | } else if(/^[a-zA-Z\s,.!?]+$/.test(text)) { 173 | return 'en-US-JennyNeural'; // 英文 174 | } else if(/[\u3040-\u30ff]/.test(text)) { 175 | return 'ja-JP-NanamiNeural'; // 日文 176 | } else if(/[\uAC00-\uD7AF]/.test(text)) { 177 | return 'ko-KR-SunHiNeural'; // 韩文 178 | } 179 | return 'zh-CN-XiaoxiaoNeural'; // 默认中文 180 | } 181 | ``` 182 | 183 | ### 支持的语音列表 184 | 185 | > 完整的语音支持列表请参考[微软官方文档](https://learn.microsoft.com/zh-cn/azure/cognitive-services/speech-service/language-support?tabs=tts) 186 | 187 | | 语音代码 | 描述 | 语言 | 188 | |----------|------|------| 189 | | zh-CN-XiaoxiaoNeural | 晓晓 - 温暖活泼 | 中文 | 190 | | zh-CN-XiaoyiNeural | 晓伊 - 温暖亲切 | 中文 | 191 | | zh-CN-YunxiNeural | 云希 - 男声,稳重 | 中文 | 192 | | zh-CN-YunyangNeural | 云扬 - 男声,专业 | 中文 | 193 | | zh-CN-XiaohanNeural | 晓涵 - 自然流畅 | 中文 | 194 | | zh-CN-XiaomengNeural | 晓梦 - 甜美活力 | 中文 | 195 | | zh-CN-XiaochenNeural | 晓辰 - 温和从容 | 中文 | 196 | | zh-CN-XiaoruiNeural | 晓睿 - 男声,儒雅 | 中文 | 197 | | zh-CN-XiaoshuangNeural | 晓双 - 女声,温柔 | 中文 | 198 | | zh-CN-YunfengNeural | 云枫 - 男声,成熟 | 中文 | 199 | | zh-CN-YunjianNeural | 云健 - 男声,阳光 | 中文 | 200 | | zh-CN-XiaoxuanNeural | 晓萱 - 女声,知性 | 中文 | 201 | | zh-CN-YunxiaNeural | 云夏 - 男声,青春 | 中文 | 202 | | zh-CN-XiaomoNeural | 晓墨 - 女声,优雅 | 中文 | 203 | | zh-CN-XiaozhenNeural | 晓甄 - 女声,自信 | 中文 | 204 | | en-US-JennyNeural | Jenny | 英文 | 205 | | en-US-GuyNeural | Guy | 英文 | 206 | | ja-JP-NanamiNeural | Nanami | 日文 | 207 | | ja-JP-KeitaNeural | Keita | 日文 | 208 | | ko-KR-SunHiNeural | Sun-Hi | 韩文 | 209 | | ko-KR-InJoonNeural | InJoon | 韩文 | 210 | 211 | #### 情绪风格参数 212 | | 风格参数 | 效果描述 | 适用场景 | 213 | |---------|---------|---------| 214 | | angry | 愤怒语气 | 情感强烈的对话 | 215 | | chat | 轻松闲聊 | 日常对话交流 | 216 | | cheerful | 开心愉悦 | 欢快场景表达 | 217 | | sad | 悲伤情绪 | 抒情感伤场景 | 218 | 219 | 更多语音风格和参数设置请参考[微软语音合成标记文档](https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/speech-synthesis-markup-voice) 220 | 221 | ## 📝 注意事项 222 | 223 | 1. 本项目仅供学习和个人使用 224 | 2. 不建议用于商业项目 225 | 3. API 可能随时失效,请谨慎使用 226 | 4. 建议在生产环境使用官方付费 API 227 | 5. **重要:请使用与语音对应的语言文本,否则可能会转换失败** 228 | 6. 建议文本长度不要太长,以避免请求超时 229 | 230 | ## ❓ 常见问题 231 | 232 | 1. **Q: 为什么转换失败了?** 233 | A: 常见原因: 234 | - 使用了与语音不匹配的语言(如用英文语音转换中文文本) 235 | - API Key 错误或认证头部格式不正确 236 | - 请求参数格式不正确 237 | - 文本过长 238 | - 服务暂时不可用 239 | 240 | 2. **Q: 支持哪些音频格式?** 241 | A: 目前仅支持 MP3 格式 242 | 243 | 3. **Q: 有请求限制吗?** 244 | A: Cloudflare Workers 免费版每天有 100,000 次请求限制 245 | 246 | 4. **Q: 如何调整语音效果?** 247 | A: 可以通过调整 speed 参数(范围 0.5-2.0)来改变语速 248 | 249 | 5. **Q: 如何使用自定义域名?** 250 | A: 有两种方式: 251 | - 方式一:直接使用 Workers 提供的子域名 252 | - 方式二:使用自己的域名 253 | 1. 域名需要先托管在 Cloudflare 254 | 2. 在 Worker 设置中添加自定义域名 255 | 3. 等待 DNS 生效(通常几分钟) 256 | 4. 使用自定义域名访问 API 257 | 258 | 6. **Q: 为什么要使用自定义域名?** 259 | A: 使用自定义域名有以下好处: 260 | - 更专业的品牌形象 261 | - 更容易记忆的地址 262 | - 可以随时更换底层服务而保持 API 地址不变 263 | - 便于管理多个 Workers 264 | 265 | 7. **Q: 自定义域名需要付费吗?** 266 | A: 这取决于你的 Cloudflare 计划: 267 | - 免费计划:可以使用自定义域名,但有一些限制 268 | - 付费计划:有更多功能和更高的限制 269 | 270 | ## 📄 许可证 271 | 272 | MIT License 273 | 274 | ## 🤝 贡献 275 | 276 | 欢迎提交 Issue 和 Pull Request! 277 | 278 | 279 | 280 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | let expiredAt = null; 3 | let endpoint = null; 4 | let clientId = "76a75279-2ffa-4c3d-8db8-7b47252aa41c"; 5 | 6 | const API_KEY = globalThis.API_KEY; 7 | 8 | // 添加缓存和预刷新机制 9 | const TOKEN_REFRESH_BEFORE_EXPIRY = 5 * 60; // 提前5分钟刷新token 10 | let tokenInfo = { 11 | endpoint: null, 12 | token: null, 13 | expiredAt: null 14 | }; 15 | 16 | // 在文件顶部常量定义区域添加映射表 17 | const VOICE_MAPPING = { 18 | 'alloy': 'zh-CN-XiaoxiaoNeural', 19 | 'echo': 'zh-CN-YunxiNeural', 20 | 'fable': 'zh-CN-XiaoyiNeural', 21 | 'onyx': 'zh-CN-YunyangNeural', 22 | 'nova': 'zh-CN-XiaohanNeural', 23 | 'shimmer': 'zh-CN-XiaomengNeural' 24 | }; 25 | 26 | addEventListener("fetch", event => { 27 | event.respondWith(handleRequest(event.request)); 28 | }); 29 | 30 | async function handleRequest(request) { 31 | if (request.method === "OPTIONS") { 32 | return handleOptions(request); 33 | } 34 | 35 | // 只在设置了 API_KEY 的情况下才验证 36 | if (API_KEY) { 37 | const authHeader = request.headers.get("authorization"); 38 | const apiKey = authHeader?.startsWith("Bearer ") 39 | ? authHeader.slice(7) 40 | : null; 41 | 42 | if (apiKey !== API_KEY) { 43 | return new Response(JSON.stringify({ 44 | error: { 45 | message: "Invalid API key. Use 'Authorization: Bearer your-api-key' header", 46 | type: "invalid_request_error", 47 | param: null, 48 | code: "invalid_api_key" 49 | } 50 | }), { 51 | status: 401, 52 | headers: { 53 | "Content-Type": "application/json", 54 | ...makeCORSHeaders() 55 | } 56 | }); 57 | } 58 | } 59 | 60 | const requestUrl = new URL(request.url); 61 | const path = requestUrl.pathname; 62 | 63 | if (path === "/v1/audio/speech") { 64 | try { 65 | const requestBody = await request.json(); 66 | let { 67 | model = "tts-1", 68 | input, 69 | voice = "zh-CN-XiaoxiaoNeural", 70 | response_format = "mp3", 71 | speed = 1.0, 72 | pitch = 1.0, 73 | style = "general" 74 | } = requestBody; 75 | 76 | // 添加语音名称映射 77 | voice = VOICE_MAPPING[voice] || voice; // 如果存在映射则替换,否则保持原值 78 | 79 | const rate = ((speed - 1) * 100).toFixed(0); 80 | const numPitch = ((pitch - 1) * 100).toFixed(0); // 将 pitch 参数转换为百分比形式 81 | const response = await getVoice( 82 | input, 83 | voice, 84 | rate, 85 | numPitch, 86 | style, 87 | "audio-24khz-48kbitrate-mono-mp3", 88 | false 89 | ); 90 | 91 | return response; 92 | 93 | } catch (error) { 94 | console.error("Error:", error); 95 | return new Response(JSON.stringify({ 96 | error: { 97 | message: error.message, 98 | type: "api_error", 99 | param: null, 100 | code: "edge_tts_error" 101 | } 102 | }), { 103 | status: 500, 104 | headers: { 105 | "Content-Type": "application/json", 106 | ...makeCORSHeaders() 107 | } 108 | }); 109 | } 110 | } 111 | 112 | // 默认返回 404 113 | return new Response("Not Found", { status: 404 }); 114 | } 115 | 116 | async function handleOptions(request) { 117 | return new Response(null, { 118 | status: 204, 119 | headers: { 120 | ...makeCORSHeaders(), 121 | "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS", 122 | "Access-Control-Allow-Headers": request.headers.get("Access-Control-Request-Headers") || "Authorization" 123 | } 124 | }); 125 | } 126 | 127 | async function getVoice(text, voiceName = "zh-CN-XiaoxiaoNeural", rate = 0, pitch = 0, style = "general", outputFormat = "audio-24khz-48kbitrate-mono-mp3", download = false) { 128 | try { 129 | const maxChunkSize = 2000; // 假设每次请求的最大文本长度为2000字符 130 | const chunks = []; 131 | 132 | // 将长文本分段 133 | for (let i = 0; i < text.length; i += maxChunkSize) { 134 | const chunk = text.slice(i, i + maxChunkSize); 135 | chunks.push(chunk); 136 | } 137 | 138 | // 获取每个分段的音频 139 | const audioChunks = await Promise.all(chunks.map(chunk => getAudioChunk(chunk, voiceName, rate, pitch, style, outputFormat))); 140 | 141 | // 将音频片段拼接起来 142 | const concatenatedAudio = new Blob(audioChunks, { type: 'audio/mpeg' }); 143 | const response = new Response(concatenatedAudio, { 144 | headers: { 145 | "Content-Type": "audio/mpeg", 146 | ...makeCORSHeaders() 147 | } 148 | }); 149 | 150 | if (download) { 151 | response.headers.set("Content-Disposition", `attachment; filename="${uuid()}.mp3"`); 152 | } 153 | 154 | return response; 155 | 156 | } catch (error) { 157 | console.error("语音合成失败:", error); 158 | return new Response(JSON.stringify({ 159 | error: { 160 | message: error.message, 161 | type: "api_error", 162 | param: null, 163 | code: "edge_tts_error" 164 | } 165 | }), { 166 | status: 500, 167 | headers: { 168 | "Content-Type": "application/json", 169 | ...makeCORSHeaders() 170 | } 171 | }); 172 | } 173 | } 174 | 175 | 176 | //获取单个音频数据 177 | async function getAudioChunk(text, voiceName, rate, pitch, style, outputFormat) { 178 | const endpoint = await getEndpoint(); 179 | const url = `https://${endpoint.r}.tts.speech.microsoft.com/cognitiveservices/v1`; 180 | 181 | const response = await fetch(url, { 182 | method: "POST", 183 | headers: { 184 | "Authorization": endpoint.t, 185 | "Content-Type": "application/ssml+xml", 186 | "User-Agent": "okhttp/4.5.0", 187 | "X-Microsoft-OutputFormat": outputFormat 188 | }, 189 | body: getSsml(text, voiceName, rate, pitch, style) 190 | }); 191 | 192 | if (!response.ok) { 193 | const errorText = await response.text(); 194 | throw new Error(`Edge TTS API error: ${response.status} ${errorText}`); 195 | } 196 | 197 | return response.blob(); 198 | } 199 | 200 | function getSsml(text, voiceName, rate, pitch,style) { 201 | return ` 202 | 203 | 204 | ${text} 205 | 206 | 207 | `; 208 | 209 | } 210 | 211 | // 优化 getEndpoint 函数 212 | async function getEndpoint() { 213 | const now = Date.now() / 1000; 214 | 215 | // 检查token是否有效(提前5分钟刷新) 216 | if (tokenInfo.token && tokenInfo.expiredAt && now < tokenInfo.expiredAt - TOKEN_REFRESH_BEFORE_EXPIRY) { 217 | console.log(`使用缓存的token,剩余 ${((tokenInfo.expiredAt - now) / 60).toFixed(1)} 分钟`); 218 | return tokenInfo.endpoint; 219 | } 220 | 221 | // 获取新token 222 | const endpointUrl = "https://dev.microsofttranslator.com/apps/endpoint?api-version=1.0"; 223 | const clientId = crypto.randomUUID().replace(/-/g, ""); 224 | 225 | try { 226 | const response = await fetch(endpointUrl, { 227 | method: "POST", 228 | headers: { 229 | "Accept-Language": "zh-Hans", 230 | "X-ClientVersion": "4.0.530a 5fe1dc6c", 231 | "X-UserId": "0f04d16a175c411e", 232 | "X-HomeGeographicRegion": "zh-Hans-CN", 233 | "X-ClientTraceId": clientId, 234 | "X-MT-Signature": await sign(endpointUrl), 235 | "User-Agent": "okhttp/4.5.0", 236 | "Content-Type": "application/json; charset=utf-8", 237 | "Content-Length": "0", 238 | "Accept-Encoding": "gzip" 239 | } 240 | }); 241 | 242 | if (!response.ok) { 243 | throw new Error(`获取endpoint失败: ${response.status}`); 244 | } 245 | 246 | const data = await response.json(); 247 | const jwt = data.t.split(".")[1]; 248 | const decodedJwt = JSON.parse(atob(jwt)); 249 | 250 | // 更新缓存 251 | tokenInfo = { 252 | endpoint: data, 253 | token: data.t, 254 | expiredAt: decodedJwt.exp 255 | }; 256 | 257 | console.log(`获取新token成功,有效期 ${((decodedJwt.exp - now) / 60).toFixed(1)} 分钟`); 258 | return data; 259 | 260 | } catch (error) { 261 | console.error("获取endpoint失败:", error); 262 | // 如果有缓存的token,即使过期也尝试使用 263 | if (tokenInfo.token) { 264 | console.log("使用过期的缓存token"); 265 | return tokenInfo.endpoint; 266 | } 267 | throw error; 268 | } 269 | } 270 | 271 | function addCORSHeaders(response) { 272 | const newHeaders = new Headers(response.headers); 273 | for (const [key, value] of Object.entries(makeCORSHeaders())) { 274 | newHeaders.set(key, value); 275 | } 276 | return new Response(response.body, { ...response, headers: newHeaders }); 277 | } 278 | 279 | function makeCORSHeaders() { 280 | return { 281 | "Access-Control-Allow-Origin": "*", // 可以将 "*" 替换为特定的来源,例如 "https://9a17e592.text2voice.pages.dev" 282 | "Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS", 283 | "Access-Control-Allow-Headers": "Content-Type, x-api-key", 284 | "Access-Control-Max-Age": "86400" // 允许OPTIONS请求预检缓存的时间 285 | }; 286 | } 287 | 288 | async function hmacSha256(key, data) { 289 | const cryptoKey = await crypto.subtle.importKey( 290 | "raw", 291 | key, 292 | { name: "HMAC", hash: { name: "SHA-256" } }, 293 | false, 294 | ["sign"] 295 | ); 296 | const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data)); 297 | return new Uint8Array(signature); 298 | } 299 | 300 | async function base64ToBytes(base64) { 301 | const binaryString = atob(base64); 302 | const bytes = new Uint8Array(binaryString.length); 303 | for (let i = 0; i < binaryString.length; i++) { 304 | bytes[i] = binaryString.charCodeAt(i); 305 | } 306 | return bytes; 307 | } 308 | 309 | async function bytesToBase64(bytes) { 310 | return btoa(String.fromCharCode.apply(null, bytes)); 311 | } 312 | 313 | function uuid() { 314 | return crypto.randomUUID().replace(/-/g, ""); 315 | } 316 | 317 | async function sign(urlStr) { 318 | const url = urlStr.split("://")[1]; 319 | const encodedUrl = encodeURIComponent(url); 320 | const uuidStr = uuid(); 321 | const formattedDate = dateFormat(); 322 | const bytesToSign = `MSTranslatorAndroidApp${encodedUrl}${formattedDate}${uuidStr}`.toLowerCase(); 323 | const decode = await base64ToBytes("oik6PdDdMnOXemTbwvMn9de/h9lFnfBaCWbGMMZqqoSaQaqUOqjVGm5NqsmjcBI1x+sS9ugjB55HEJWRiFXYFw=="); 324 | const signData = await hmacSha256(decode, bytesToSign); 325 | const signBase64 = await bytesToBase64(signData); 326 | return `MSTranslatorAndroidApp::${signBase64}::${formattedDate}::${uuidStr}`; 327 | } 328 | 329 | function dateFormat() { 330 | const formattedDate = (new Date()).toUTCString().replace(/GMT/, "").trim() + " GMT"; 331 | return formattedDate.toLowerCase(); 332 | } 333 | 334 | // 添加请求超时控制 335 | async function fetchWithTimeout(url, options, timeout = 30000) { 336 | const controller = new AbortController(); 337 | const id = setTimeout(() => controller.abort(), timeout); 338 | 339 | try { 340 | const response = await fetch(url, { 341 | ...options, 342 | signal: controller.signal 343 | }); 344 | clearTimeout(id); 345 | return response; 346 | } catch (error) { 347 | clearTimeout(id); 348 | throw error; 349 | } 350 | } --------------------------------------------------------------------------------