当微软 Edge TTS 被墙之后:一次 API 调用的迂回作战
引子
故事发生在一个再普通不过的夜晚。
我在维护一个英语学习 Web 应用,其中一个核心功能是发音朗读——用户查词或练 dictation 时,点击按钮就能听到单词的标准发音。
之前这个功能一直用着微软 Edge TTS,走的 @lobehub/tts 库,Node.js 进程内拉起 WebSocket 直连 speech.platform.bing.com,稳定跑了大半年。
直到有一天,用户反馈说:"听不到声音了。"
第一轮排查:代码层面看起来是好的
本地 npm run dev 打开浏览器,点发音按钮——正常出声。再点,也正常。
上生产服务器测——
WebSocket error occurred
嗯。curl 一下:
$ curl https://speech.platform.bing.com
Our services aren't available right now
破案了。从国内服务器到微软 Edge TTS 的网络链路断了。 可能是最近网络策略的调整,也可能是某条海缆出了状况——总之,speech.platform.bing.com 从国内无法直连了。
而本地能出声,是因为我开着 Clash。
架构的剖面
在深入改代码之前,先看一眼当时的调用链路:
浏览器 → Next.js API (/api/tts) → @lobehub/tts (EdgeSpeechTTS)
→ WebSocket → speech.platform.bing.com
↓
被阻断 ❌
↓
回退到浏览器 SpeechSynthesis(质量差、不稳定)
客户端侧有一个回退机制:如果服务器 TTS 挂了,就 fallback 到浏览器自带的 SpeechSynthesis。但那个引擎的中英文混读效果一言难尽,属于能响但不好听的水平。
翻项目,发现一个沉睡的 vendor
我翻了翻项目仓库,发现 vendor/ 目录下躺着一个老熟人:
vendor/edgeTTS-openai-api/
这是一个用 Flask + edge-tts(Python)写的轻量级 TTS 服务器,暴露 OpenAI 兼容的 API(POST /v1/audio/speech)。README 里写着支持一键部署到 HuggingFace Spaces。
这显然是为了解决网络问题而预留的备选方案——只是之前一直没启用。
但等等,它用的也是 edge-tts,底层也是 WebSocket 连微软。部署在同样的国内服务器上,照样连不出去。
所以问题的关键不在于用什么语言写 TTS 逻辑,而在于:从哪里发起连接。
决策树
画出拓扑之后,选项变得清晰:
我们的服务器(国内)
├─ Option A: 直接 Python 代理部署在 HuggingFace Spaces(海外)
│ → 海外直连微软,国内服务器只需 HTTP 调 HuggingFace
│
├─ Option B: 同一台服务器上跑 Python 代理 + HTTP_PROXY 翻墙
│ → 依赖上游代理的稳定性
│
└─ Option C: 买一台海外 VPS 专门跑 TTS 代理
→ 成本最高,但延迟最低
三个选项的权衡:
| 选项 | 运维成本 | 延迟 | 翻墙依赖 |
|---|---|---|---|
| A. HuggingFace Spaces | 极低(一键部署) | 中等(多一跳) | 无 |
| B. 同机 + HTTP_PROXY | 中等(要维护代理) | 低 | 是 |
| C. 海外 VPS | 高 | 最低 | 无 |
最终选了 Option A。原因很朴素:HuggingFace 免费额度够用、一键部署不需要 DevOps 技能、并且全程不需要我配任何代理或 VPN。
手术:改代码
既然决定走 proxy 路线,代码改动其实很小。TTS 是模块化的,只需替换底层实现,上层 API 和 UI 完全不动。
修改前(src/lib/tts.ts,简化版)
import { EdgeSpeechTTS } from '@lobehub/tts'
import WebSocket from 'ws'
// 注入 WebSocket 全局变量
;(globalThis as any).WebSocket = WebSocket
const tts = new EdgeSpeechTTS()
export async function synthesizeSpeech(req) {
const response = await tts.create({
input: req.input,
options: { voice, rate: speed },
signal: abortController.signal,
})
return response
}
修改后
const TTS_PROXY_URL = process.env.TTS_PROXY_URL
|| 'http://localhost:5050/v1/audio/speech'
export async function synthesizeSpeech(req) {
const response = await fetch(TTS_PROXY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
body: JSON.stringify({
input: req.input,
voice: normalizeVoice(req.voice),
speed: clampSpeed(req.speed),
response_format: 'mp3',
}),
signal: abortController.signal,
})
if (!response.ok) throw new Error('TTS proxy error')
return response
}
核心变化是:把 in-process 的 WebSocket 直连,换成了 out-of-process 的 HTTP 调用。
好处是:
- 调用方(Next.js API route)完全无感知——
synthesizeSpeech()返回的依然是Response对象 - 所有 UI 组件不动——它们调的是
speakText('/api/tts'),链路末端的变化对它们是透明的 - 后端可以随便换——改两行环境变量就能在本地代理、HuggingFace、海外 VPS 之间切换
副作用:依赖瘦身
移除 @lobehub/tts 和 ws 两个包之后,next.config.ts 里的 serverExternalPackages 也清了两项。顺便把 node_modules 里的体积倒腾掉了——不过那是另一个话题了。
目前的状态
代码层面的活干完了。改动范围:
| 文件 | 改动 |
|---|---|
src/lib/tts.ts |
全部重写,替换核心实现 |
next.config.ts |
移除废弃的 external packages |
.env / .env.example |
新增 TTS_PROXY_URL、TTS_PROXY_API_KEY |
TypeScript 编译零错误。
唯一下的部分就是:我还没部署到 HuggingFace。 昨晚只睡了六个小时,脑子不太转了,就先到这里吧。
后来我跟 AI 说:"我没有部署到 hugging face 的经验,而且我昨晚只睡了六个小时,要不先放一放,帮我写一篇博客吧。"
就有了你现在读到的这篇。
回头看的感悟
做技术的人常会下意识追求"最优雅"的解法——比如在 Node.js 里给 WebSocket 套一层代理层。但实际工程里,最简单的方案往往就是对的。
用 HuggingFace Spaces 部署一个 Flask 应用来转发 TTS 流量,技术上毫无炫酷之处。但它:
- 不需要我理解 WebSocket 代理的原理
- 不需要我在生产服务器上搭 Clash
- 零成本(HuggingFace 免费)
- 零运维
把复杂藏在简单后面,是工程的真功夫(虽然这个功夫是 AI 帮我做的)。
写完这篇博客之后,我打算去睡个好觉。TTS 的事,明天再说。
这篇博客是 5月25日 写的,今天是 5月31 日,我来说说我最后是怎么解决TTS的。
很简单,我用了小米mimo的tts模型,免费的,接个API,把key发给AI就弄好了。