
最近在做内容平台后台功能时需要把一批文章自动生成语音方便用户在通勤路上收听。调研了一圈文本转语音方案之后最终我用 Spring Boot 集成了免费的 EdgeTTS不需要申请任何 API Key直接通过 WebSocket 协议完成语音合成输出 MP3整体效果出乎意料地稳定几乎零成本跑通了整个“文章转语音”的需求。这篇博文把我从协议解析、工程落地到排查踩坑的完整过程整理出来。如果你正在做内容朗读、无障碍播报、语音助手这类功能想在 Java 项目里低成本加一个文本转语音能力这篇文章应该能帮你少走不少弯路。1. 先想清楚EdgeTTS 适合放在什么位置1.1 常见 TTS 方案的一次横向对比我在选型的时候把市面上的 TTS 方案大致分成了三类来对比方案成本接入难度音色自然度适合场景云厂商 TTS按字符或按调用次数计费低有 SDK 有文档高各家都有神经语音对音质和稳定性有明确要求的生产系统开源 TTS 自建需要 GPU/CPU 推理资源中高环境搭建和模型部署有门槛中部分模型表现不错数据敏感、需要离线部署的场景EdgeTTS免费不需要 API Key中需要自己实现协议调用高用的是 Edge 浏览器内置的神经语音内部工具、Demo、MVP、个人项目单看这个对比表EdgeTTS 似乎“性价比无敌”但我要先把丑话说在前面它不是一个正式的、有 SLA 的商业服务而是 Edge 浏览器“大声朗读”功能背后的实时语音合成接口。用它做个人项目、内部辅助工具、功能原型完全没有问题但如果做对外商业化的大规模服务授权边界和稳定性风险需要你自己评估清楚。在这种前提下EdgeTTS 的价值非常明确当你想快速验证一个“文字转音频”的产品想法或者给内部后台加一个“把文章批量朗读成 MP3”的实用功能时它让你省掉了云厂商的注册、充值、审核流程也省掉了自建模型服务器的硬件成本。1.2 哪些场景适合哪些场景要慎重适合用 EdgeTTS 的场景我按实际经验列几个内容平台的后台编辑工具一键把文章生成为语音文件供编辑试听或用作文案参考。个人博客、知识库的“听文章”功能用户量不大但对语音的自然度有要求。内部办公系统里的消息播报、任务提醒。做语音相关的 Demo 和原型验证比如对话机器人、字幕工具、在线阅读器。要慎重的场景也很明确如果你的产品每天要合成几万次语音、对响应速度有硬性要求、或者音频要作为商业资产直接对外售卖那我建议你在 EdgeTTS 之上再做一层封装并且准备一个付费 TTS 的降级方案避免上游服务异常时整个功能瘫痪。我当时的做法是在 Service 层定义一个统一的TtsProvider接口先用 EdgeTTS 实现后续如果业务量上来再换云厂商实现上层业务代码完全不用改。这个设计思路我建议你也采用后面扩展会轻松很多。2. 扒开 EdgeTTS 的协议不是说接就接的2.1 整个交互链路只有四类消息EdgeTTS 本质上是一个基于 WebSocket 的实时协议。我第一次抓包看流程的时候有点意外它不像传统 HTTP 接口那样“发一个请求、拿一个响应”而是建立长连接之后通过文本帧和二进制帧混合交互整个过程可以分为四步客户端发起 WebSocket 握手连接到一个固定的服务端点。连接建立后客户端先发送一条speech.config消息把音频格式、设备信息、是否开启词边界等参数告诉服务端。紧接着发送一条ssml消息里面包含要朗读的文本、音色、语速、音量、音调等合成参数。服务端开始持续返回消息包括音频二进制帧、音频元数据文本帧最后以turn.end文本帧通知本次合成结束。第一步的连接端点格式长这样wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1 ?TrustedClientToken6A5AA1D4EAFF4E9FB37E23D68491D6F4 Sec-MS-GEC... Sec-MS-GEC-Version1-130.1072406149 ConnectionId...这里面的TrustedClientToken是一个固定值ConnectionId是一个每次请求新生成的 UUID而Sec-MS-GEC是一个动态令牌这就是初学者最容易卡住的地方后文我会单独讲。整个合成过程是流式的服务端不是等全部音频生成完才一次性返回而是边合成边推送二进制帧客户端需要把收到的音频片段按顺序拼接起来直到收到turn.end才认为这一次合成结束。2.2 消息体结构和二进制帧格式如果直接用 WebSocket 调试工具观察你会发现每一条文本消息都带着一段“头信息”。它的格式和 HTTP 头有点像每一行是键:值头和消息正文之间用一个空行分隔X-Timestamp:Thu Jan 02 2025 10:30:00 GMT0000 Content-Type:application/json; charsetutf-8 Path:speech.config {context:{...}}Path字段用来标识这条消息的类型这是客户端解析响应的关键。音频数据是通过二进制帧传回来的而这个二进制帧的结构很多人第一次会看懵前 2 个字节表示“头部长度”后面跟着一个“头信息字符串”再往后才是真正的 MP3 音频数据。用伪代码描述就是前 2 字节headerLength大端序 第 2 到 2 headerLength 字节头部文本例如 Path:audio 开头 剩余字节音频数据这个结构在解析时必须正确处理否则很容易把头部文本当成音频写进 MP3 文件导致播放器报错。我在代码里用一个工具方法专门处理二进制帧逻辑其实不复杂但需要细心。2.3 容易被忽略的 token 参数Sec-MS-GEC是整个接入过程中最“劝退”新手的地方。它不是一个静态字符串而是基于时间和某种带密钥的散列算法动态生成的令牌。服务端会用它校验请求是否来自合法的浏览器客户端。这个令牌的算法在社区里已经有多个语言版本的实现可以参考我建议直接搜索“edge-tts token 算法”或者“Sec-MS-GEC 生成”参考已经验证过的开源实现移植到 Java千万不要自己花时间逆向。我在项目里就是参考已有实现封装了一个EdgeTtsTokenProvider类按需生成令牌。这里有一个非常实用的避坑点这个令牌对服务器时间的偏差很敏感。如果部署环境的系统时间和标准时间差太多服务端会直接拒绝连接报 403。我在第一次部署到内网服务器时就踩过这个坑排查了半天才发现是那台服务器的时间慢了将近 5 分钟。所以生产环境一定要开 NTP 时间同步。3. Spring Boot 集成实操一个能直接跑的接口3.1 工程初始化与依赖我用的是 Spring Boot 3.xJDK 17构建工具 Maven。除了 Web 依赖之外核心外带的就是 OkHttp它同时负责 WebSocket 连接和 HTTP 请求比用 JDK 自带的 WebSocket 实现更顺手。pom.xml 里需要加这些dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependencyOkHttp 4.x 是基于 Kotlin 写的但在 Java 项目里使用完全没有问题API 还是那套经典的链式风格。3.2 配置文件我把 EdgeTTS 相关的参数全部放到了application.yml里方便不同环境切换edge-tts: trust-client-token: 6A5AA1D4EAFF4E9FB37E23D68491D6F4 default-voice: zh-CN-XiaoxiaoNeural default-rate: 0% default-volume: 0% default-pitch: 0Hz audio-format: audio-24khz-48kbitrate-mono-mp3 connect-timeout-seconds: 10 synthesis-timeout-seconds: 60default-voice我用的晓晓是目前中文音色里自然度最高的一个。audio-format用的是audio-24khz-48kbitrate-mono-mp3这个格式兼顾了音质和体积后面我会展开讲。3.3 核心客户端的实现核心就是一个EdgeTtsClient组件它负责建立 WebSocket 连接、发送协议消息、接收并拼接音频数据。我把完整流程封装成一个同步方法调用方传入文本和语音参数返回 MP3 字节数组。Component public class EdgeTtsClient { private static final Logger log LoggerFactory.getLogger(EdgeTtsClient.class); private final OkHttpClient httpClient; Value(${edge-tts.trust-client-token}) private String trustClientToken; Value(${edge-tts.default-voice}) private String defaultVoice; Value(${edge-tts.default-rate}) private String defaultRate; Value(${edge-tts.default-volume}) private String defaultVolume; Value(${edge-tts.default-pitch}) private String defaultPitch; Value(${edge-tts.audio-format}) private String audioFormat; Value(${edge-tts.connect-timeout-seconds}) private int connectTimeoutSeconds; Value(${edge-tts.synthesis-timeout-seconds}) private int synthesisTimeoutSeconds; public EdgeTtsClient() { this.httpClient new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.MILLISECONDS) .pingInterval(20, TimeUnit.SECONDS) .build(); } public byte[] synthesize(String text) throws IOException, InterruptedException { return synthesize(text, defaultVoice, defaultRate, defaultVolume, defaultPitch); } public byte[] synthesize(String text, String voice, String rate, String volume, String pitch) throws IOException, InterruptedException { ByteArrayOutputStream audioBuffer new ByteArrayOutputStream(); CountDownLatch finishLatch new CountDownLatch(1); AtomicReferenceThrowable errorRef new AtomicReference(); String requestId UUID.randomUUID().toString(); String url buildWssUrl(requestId); Request request new Request.Builder() .url(url) .header(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0) .header(Origin, chrome-extension://jdiddbmdmedcbkjigjljdpbbpbdgekil) .header(Pragma, no-cache) .header(Cache-Control, no-cache) .build(); WebSocketListener listener new WebSocketListener() { Override public void onOpen(WebSocket webSocket, Response response) { log.debug(EdgeTTS 连接建立, requestId{}, requestId); sendSpeechConfig(webSocket); sendSsml(webSocket, text, voice, rate, volume, pitch); } Override public void onMessage(WebSocket webSocket, String textMessage) { handleTextMessage(webSocket, textMessage, finishLatch); } Override public void onMessage(WebSocket webSocket, ByteString bytes) { handleBinaryMessage(bytes, audioBuffer); } Override public void onClosing(WebSocket webSocket, int code, String reason) { webSocket.close(1000, null); finishLatch.countDown(); } Override public void onFailure(WebSocket webSocket, Throwable t, Response response) { errorRef.set(t); finishLatch.countDown(); } }; WebSocket webSocket httpClient.newWebSocket(request, listener); boolean finished finishLatch.await(synthesisTimeoutSeconds, TimeUnit.SECONDS); if (!finished) { webSocket.close(1000, timeout); throw new IOException(EdgeTTS 合成超时, requestId requestId); } if (errorRef.get() ! null) { throw new IOException(EdgeTTS 合成失败, requestId requestId, errorRef.get()); } return audioBuffer.toByteArray(); } }这段代码有几个设计细节我要特别说明一是每个请求都创建独立的 WebSocket、独立的ByteArrayOutputStream、独立的CountDownLatch。EdgeTTS 的 WebSocket 会话是一对一的如果多个线程共享同一个连接音频帧混在一起根本没法用。二是readTimeout设置为 0也就是无限等待。因为 WebSocket 的音频帧是边合成边推送的两帧之间的间隔可能很长如果设置了读超时音频稍微长一点就会误判为超时断开。真正的超时控制交给业务层的synthesisTimeoutSeconds来做。三是用一个AtomicReferenceThrowable接收异步回调里的异常然后把CountDownLatch倒计时让同步方法能拿到异步回调的结果。这是从异步到同步转换的经典做法。接下来是辅助方法包括发送speech.config、发送 SSML、构建 WSS URL、解析二进制帧等。private String buildWssUrl(String requestId) { String secMsGec EdgeTtsTokenProvider.generateSecMsGec(); String connectionId requestId.replace(-, ).toUpperCase(); return wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1 ?TrustedClientToken trustClientToken Sec-MS-GEC secMsGec Sec-MS-GEC-Version1-130.1072406149 ConnectionId connectionId; } private void sendSpeechConfig(WebSocket webSocket) { String timestamp currentTimestamp(); String config {\context\:{\synthesis\:{\audio\:{ \metadataoptions\:{\sentenceBoundaryEnabled\:\false\,\wordBoundaryEnabled\:\true\}, \outputFormat\:\ audioFormat \}}, \os\:{\platform\:\Edge\,\name\:\Chrome\,\version\:\125.0.0.0\}}}; String message buildMessage(timestamp, application/json; charsetutf-8, speech.config, config); webSocket.send(message); } private void sendSsml(WebSocket webSocket, String text, String voice, String rate, String volume, String pitch) { String timestamp currentTimestamp(); String escapedText escapeXml(text); String lang resolveLang(voice); String ssml speak version1.0 xmlnshttp://www.w3.org/2001/10/synthesis xml:lang lang voice name voice prosody pitch pitch rate rate volume volume escapedText /prosody/voice/speak; String message buildMessage(timestamp, application/ssmlxml, ssml, ssml); webSocket.send(message); } private String buildMessage(String timestamp, String contentType, String path, String body) { return X-Timestamp: timestamp \r\n Content-Type: contentType \r\n Path: path \r\n \r\n body; } private void handleTextMessage(WebSocket webSocket, String textMessage, CountDownLatch finishLatch) { if (textMessage.startsWith(Path:turn.end)) { webSocket.close(1000, turn.end); finishLatch.countDown(); return; } // 其他文本消息包括元数据、日志等当前先忽略。 // 如果需要按词高亮可以在这里解析 audio.metadata 的 JSON。 } private void handleBinaryMessage(ByteString bytes, ByteArrayOutputStream audioBuffer) { if (bytes.size() 2) { return; } int headerLength bytes.getShort(0) 0xFFFF; if (bytes.size() 2 headerLength) { return; } String header bytes.substring(2, 2 headerLength).utf8(); if (header.startsWith(Path:audio)) { byte[] payload bytes.substring(2 headerLength).toByteArray(); try { audioBuffer.write(payload); } catch (IOException e) { log.error(写入音频数据失败, e); } } } private String currentTimestamp() { return DateTimeFormatter .ofPattern(EEE MMM dd yyyy HH:mm:ss GMT0000 (Coordinated Universal Time), Locale.US) .format(ZonedDateTime.now(ZoneOffset.UTC)); } private String escapeXml(String text) { StringBuilder sb new StringBuilder(text.length()); for (int i 0; i text.length(); i) { char c text.charAt(i); switch (c) { case - sb.append(amp;); case - sb.append(lt;); case - sb.append(gt;); case - sb.append(quot;); case \ - sb.append(apos;); default - sb.append(c); } } return sb.toString(); } private String resolveLang(String voice) { // 例如 zh-CN-XiaoxiaoNeural - zh-CN if (voice ! null voice.length() 5) { return voice.substring(0, 5); } return zh-CN; }EdgeTtsTokenProvider类在项目里负责生成Sec-MS-GEC令牌。它在每个请求前都要被调用一次所以不能缓存静态值必须实时生成。3.4 业务封装与 REST 接口有了EdgeTtsClient之后上层业务就非常简单了。我加了一个TtsService负责文件保存和业务兜底逻辑然后暴露一个 REST 接口POST 一段文本直接返回 MP3 文件。Service public class TtsService { private final EdgeTtsClient edgeTtsClient; public TtsService(EdgeTtsClient edgeTtsClient) { this.edgeTtsClient edgeTtsClient; } public byte[] synthesizeToBytes(String text) throws IOException, InterruptedException { if (text null || text.isBlank()) { throw new IllegalArgumentException(文本不能为空); } return edgeTtsClient.synthesize(text); } }RestController RequestMapping(/api/tts) public class TtsController { private final TtsService ttsService; public TtsController(TtsService ttsService) { this.ttsService ttsService; } PostMapping(value /synthesize, produces audio/mpeg) public ResponseEntitybyte[] synthesize(RequestBody MapString, String body) { String text body.get(text); String voice body.getOrDefault(voice, zh-CN-XiaoxiaoNeural); byte[] audio; try { audio ttsService.synthesizeToBytes(text); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filename\tts.mp3\) .body(audio); } }接口返回audio/mpeg类型浏览器和前端播放器可以直接播放也可以下载保存。4. 真正上线前要处理的几个细节4.1 音频格式与码率选择EdgeTTS 支持好几种输出格式我用的是audio-24khz-48kbitrate-mono-mp3。这个格式是 24kHz 采样率、48kbps 码率的单声道 MP3。对语音来说这个参数完全够用体积也控制得很好。一般来说一分钟的语音大约在 360KB 左右。如果你的场景对音质要求更高可以选择audio-24khz-96kbitrate-mono-mp3体积会翻倍但听感提升有限。如果你的场景需要极低带宽可以试试audio-24khz-16kbitrate-mono-mp3但音质会有明显压缩感不建议用于面向用户的功能。这个参数需要在speech.config消息的context.synthesis.audio.outputFormat字段里指定。我项目中是通过audio-format配置项控制的想换格式只需要改配置不需要动代码。4.2 长文本的处理思路EdgeTTS 单次请求虽然能合成很长的文本但我实测下来超过一定长度之后有两个问题一是网络波动导致中途断流的概率升高二是异常重试成本太高一次长文本合成就可能要 1 到 2 分钟如果最后一条音频帧没收到前面的工作全部白费。我当时的处理策略是在调用合成之前先把文本按自然段落或者句子拆分成多个片段每个片段控制在 1500 个字符以内然后逐个合成 MP3 片段最后按顺序拼接或者干脆输出多个文件让前端按列表播放。这里有几种具体做法如果音频是用于后台试听可以直接输出多个 MP3 文件文件名带序号。如果必须合并成一个文件简单拼接大多数播放器也能正常播放因为 MP3 本身是流式格式。但严格来说帧与帧之间可能存在微小间隙追求高质量时可以用音频处理库重新编码。如果文本里的标题、正文需要不同的音色或语速也可以按语义块拆解后再分别合成灵活性反而更高。我最终是选择“拆段合成 输出多个文件”的方案因为后台功能本来就需要按章节展示音频这样设计更贴合业务。4.3 并发与连接管理EdgeTTS 的每个合成请求都要建立一个 WebSocket 连接用完之后就关闭。这个连接的建立过程并不算特别轻量因此接口的并发能力是有上限的。我观察到的情况是在服务器资源正常的情况下单机用 8 到 16 个并发请求同时合成是相对安全的范围再高就会出现连接失败或者偶发超时。控制并发的方法有很多我用的是 Java 信号量简单有效private final Semaphore semaphore new Semaphore(8); public byte[] synthesizeWithLimit(String text) throws IOException, InterruptedException { if (!semaphore.tryAcquire(30, TimeUnit.SECONDS)) { throw new IOException(系统繁忙请稍后重试); } try { return edgeTtsClient.synthesize(text); } finally { semaphore.release(); } }这个信号量相当于给 EdgeTTS 的访问加了一个闸门避免瞬时高峰把连接池打爆。等到后续业务量上去了可以考虑改成分布式限流但原理是一样的。4.4 结果缓存与文件管理很多合成请求的文本是固定的比如欢迎语、系统通知、产品介绍。如果每次都重新请求 EdgeTTS既浪费资源又增加出错概率。我在项目里引入了 Caffeine 做本地缓存key 是文本加语音参数的组合value 是 MP3 字节数组非常简单。private final CacheString, byte[] cache Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(Duration.ofDays(7)) .build(); public byte[] synthesizeWithCache(String text, String voice) throws IOException, InterruptedException { String key voice : text; byte[] audio cache.getIfPresent(key); if (audio ! null) { return audio; } audio edgeTtsClient.synthesize(text, voice, 0%, 0%, 0Hz); cache.put(key, audio); return audio; }如果文本较长、数量较多建议直接把 MP3 文件持久化到磁盘或者对象存储这样即使应用重启音频文件依然存在。文件命名的规则可以用文本的 MD5 加上语音参数作为文件名天然去重。5. 常见问题与排查实录5.1 连接建立失败返回 403这是最常遇到的问题。表现是onFailure回调触发错误信息里带着HTTP 403。通常有三个原因第一个原因是Sec-MS-GEC令牌计算错误或者令牌已经过期。这个令牌是基于时间动态生成的如果服务器时间和标准时间差太多服务端校验直接失败。先检查部署机器的系统时间再看令牌生成逻辑是否参考了最新的开源实现。第二个原因是出网 IP 被服务端限制。如果短时间内发起了大量连接服务端会临时限制该 IP 的访问。这种情况只能等一段时间再重试或者降低调用频率。第三个原因是 WebSocket 握手头不对。有时候需要带上和真实浏览器一致的User-Agent和Origin服务端会校验这些字段。我在代码里已经写好了但如果你是自己用调试工具测试别忘了加上。5.2 合成后音频为空或者时长只有几秒音频为空大概率是 SSML 中的文本转义出了问题。如果原文里包含、、这些 XML 特殊字符拼接到 SSML 里之后会导致整个 XML 结构被破坏服务端可能返回空音频或直接报错。这个问题很容易被忽视因为普通文本测试的时候完全正常一旦遇到A B这种带特殊字符的内容就出问题。解决办法就是我在代码里写的escapeXml方法在拼 SSML 之前对文本做一次全量转义。还有一个容易忽略的点如果你用的是英文音色文本里却有中文或者反过来服务端的表现可能会很奇怪。建议根据文本实际语言选择合适的音色或者按语言拆分成多个片段分别合成。5.3 接口偶发超时音频合到一半不推了这类问题我遇到不止一次。长音频合成过程中如果服务器 GC 停顿、网络抖动都可能造成音频帧推送间隙变长。如果代码里设置了读超时很容易误杀正常连接。我的解决办法是WebSocket 本身不设读超时用业务层的CountDownLatch.await(timeout)来控制整体耗时。超时时间不要设太短建议至少 60 秒长文本可以按文本长度动态计算。如果超时频繁发生还要检查是否有多线程共享同一个ByteArrayOutputStream的问题。EdgeTTS 的 WebSocket 回调是异步的如果同一个连接被多个线程复用音频帧会串表现就是音频内容错乱、时长异常、输出不完整。5.4 解析二进制帧时越界服务端返回的二进制帧并不总是结构完整偶尔会有headerLength加上头部内容超出整个 frame 长度的情况。我在handleBinaryMessage里做了边界判断小于 2 字节直接忽略头部长度超出剩余长度也直接忽略。这种防御性写在协议对接的代码里很重要因为边缘情况的概率虽然低但一旦发生就是一把梭式崩溃直接影响接口稳定性。5.5 完整的排查建议我把平时排查 EdgeTTS 问题的顺序固定下来基本上能覆盖九成问题先看连接是否建立没建立查网络、token、时间。连接建立但没有音频返回查 SSML 转义、音色名称、语言代码。返回音频但播放不了查二进制帧解析是不是把头部混进了音频数据。返回音频但内容错乱查并发隔离和输出缓冲是否被多线程共用。偶发失败、时好时坏查访问频率、IP 限制、服务器时间同步。按照这个顺序排查大部分问题都能在几分钟内定位。另外我建议在EdgeTtsClient里把requestId和失败原因通过日志打出来线上出问题的时候能直接串起来查。我在实际跑这个功能的时候还有个体会EdgeTTS 最适合的定位是“低成本快速验证”而不是“永久生产依赖”。你可以先用它把整个链路跑通把业务逻辑打磨好等到用户量上来再平滑替换成商业 TTS。替换的时候只要 TtsProvider 接口抽象做得好底层从 EdgeTTS 换成云厂商改动量其实很小。最后分享一个小技巧如果你需要给音频做“逐字高亮”也就是让字幕跟着朗读进度走别忘了speech.config里我已经开启了wordBoundaryEnabled: true。服务端返回的audio.metadata消息里会带上每个词的偏移量和时长拿到之后就可以在播放端实现跟读效果。这个功能我当时没来得及做但协议层已经准备好了算是一个后续可以扩展的方向。