基于 7 篇微信长文 POC + 线上生产环境的实战经验。覆盖从建连到拿到 mp3 的全流程,以及 11 条踩坑记录。
项值协议WebSocket 二进制协议 v3地址wss://openspeech.bytedance.com/api/v3/sami/podcasttts鉴权4 个 Header: X-Api-App-Id、 X-Api-Access-Key、 X-Api-Resource-Id、 X-Api-App-KeyResource IDvolc.service_type.10050输出MP3, 96kbps, 24kHz mono(固定)
豆包使用自定义二进制帧, 不是 标准 JSON WebSocket。
text
[0x11, 0x14, 0x10, 0x00]
text
Pre-connection: header(4) + event_type(4, big-endian) + payload_size(4) + payload
Post-connection: header(4) + event_type(4) + sid_len(4) + session_id + payload_size(4) + payload
Python 版本见 scripts/generate_podcast.py。Node.js 版本:
javascript
const HEADER = Buffer.from([0x11, 0x14, 0x10, 0x00]);
function preFrame(event, payload) {
const p = Buffer.from(JSON.stringify(payload));
const e = Buffer.alloc(4); e.writeUInt32BE(event);
const l = Buffer.alloc(4); l.writeUInt32BE(p.length);
return Buffer.concat([HEADER, e, l, p]);
}
function postFrame(event, sid, payload) {
const sb = Buffer.from(sid);
const p = Buffer.from(JSON.stringify(payload));
const e = Buffer.alloc(4); e.writeUInt32BE(event);
const sl = Buffer.alloc(4); sl.writeUInt32BE(sb.length);
const pl = Buffer.alloc(4); pl.writeUInt32BE(p.length);
return Buffer.concat([HEADER, e, sl, sb, pl, p]);
}
function parseEvent(data) {
const buf = Buffer.from(data);
if (buf.length < 8) return { eventType: null, payload: {} };
const mt = (buf[1] >> 4) & 0xF;
const fl = buf[1] & 0xF;
const ser = (buf[2] >> 4) & 0xF;
if (mt === 0xF) { /* 错误帧 */ return { eventType: -1, payload: {} }; }
const evt = buf.readUInt32BE(4);
let off = 8, payload = {};
if (fl & 0x04) {
const sl = buf.readUInt32BE(off); off += 4 + sl; // 跳过 session_id
const pl = buf.readUInt32BE(off); off += 4;
if (pl > 0 && ser === 1) try { payload = JSON.parse(buf.slice(off, off + pl).toString()); } catch {}
}
return { eventType: evt, payload };
}
text
客户端 豆包 API
│── StartConnection(event=1) ────→│
│←── ConnectionStarted(event=50) ─│ ← session_id 在二进制帧中
│── StartSession(event=100) ─────→│ ← 携带播客参数
│←── SessionStarted(event=150) ───│
│ ┌── 流式循环 ──────────────────┐
│←─┤ 360: RoundStart (JSON) │ ← 文案文本(第1轮为空!见坑10)
│←─┤ 361: RoundResp (binary) │ ← 音频块 ~4.6KB/chunk
│←─┤ 362: RoundEnd (JSON) │
│ └─────────────────────────────┘
│←── 363: PodcastEnd (JSON) ─────│ ← audio_url(⚠️ duration_sec 可能为 0,见坑9)
│←── 152: SessionFinished ───────│ ← ⚠️ 经常不来,见坑2
│── FinishConnection(event=2) ──→│
event名称payload关键字段1/2Start/FinishConnection{}—50ConnectionStarted二进制session_id 在帧中提取100StartSessionJSON播客参数360RoundStartJSONtext(第 1 轮为空)361RoundRespbinary音频块363PodcastEndJSONmeta_info.audio_url
javascript
const buf = Buffer.from(data);
let off = 8;
const sidLen = buf.readUInt32BE(off); off += 4;
const sessionId = buf.slice(off, off + sidLen).toString();
javascript
{
input_info: { input_url: "https://...", return_audio_url: true }, // ⚠️ url 在 input_info 内
use_head_music: true, use_tail_music: false,
audio_config: { format: "mp3", sample_rate: 24000, speech_rate: 0 },
speaker_info: { random_order: true, speakers: ["zh_male_dayixiansheng_v2_saturn_bigtts", "zh_female_mizaitongxue_v2_saturn_bigtts"] }
}
javascript
{
input_text: "文本内容...", // ⚠️ text 在顶层,不是 input_info
audio_config: { ... },
speaker_info: { ... }
}
text
❌ {"input_url": "https://..."} → 顶层没这字段,静默失败
✅ {"input_info": {"input_url": "https://..."}} → URL 模式正确用法
✅ {"input_text": "你好世界"} → 短文本模式正确用法
搞反了不会报错,只会得到空结果。
SessionFinished(152) 经常不来或等 10 分钟+。PodcastEnd(363) 拿到 audio_url 后 立即 break。
javascript
if (eventType === 363) {
const url = payload.meta_info?.audio_url || '';
ws.close(); // 立即关闭!
resolve({ audioUrl: url });
}
不加 break,5 分钟播客可能跑 15 分钟。这是最致命的坑。
文章长度播客时长生成耗时2000字5 min2.5 min5000字10 min4 min25000字30 min~10 min
统一设 TIMEOUT = 900(15 分钟)。
Python: ping_timeout=120,不设的话长文生成时会心跳超时断开。
Node.js: ws 库默认无 ping,不需要额外设置。
音频块是完整 MP3 片段,直接拼接就是可播放文件。断连时不要丢弃已收到的块。
CDN URL 签名过期后返回 403。 生成完成后立即下载 mp3 到自己的存储。
后台运行加 python -u(unbuffered)才能看到实时日志。
浏览器 new WebSocket(url) 无法设置 HTTP Header。豆包需要 4 个鉴权 Header → 必须走服务端代理。
推荐架构:
text
浏览器 ── POST /api/podcast ──→ 服务端 ── WSS + Header ──→ 豆包
← SSE 流式进度 ──────── ← 二进制帧 ──────
服务端用 Node.js ws 库(支持自定义 Header)连接豆包,通过 SSE(Server-Sent Events)把进度推给浏览器。凭证存在服务端,不暴露给前端。
PodcastEnd 的 meta_info.duration_sec 实测经常为 0 或不返回。
备用方案:根据音频大小估算(96kbps = 12KB/s):
javascript
const durationSec = meta.duration_sec || Math.round(totalAudioBytes / 12000);
第 1 轮是片头音乐, text 字段为空字符串。 第 2 轮开始 才有实际文案内容。
如果需要从文案中提取标题/主题,从 Round 2+ 的文案中取。
从中国云服务器 fetch 微信文章会返回"环境异常"页面(反爬)。标题提取备用方案:
fetch 文章页面提取 <title> 或 og:title(非微信 URL 有效)豆包 token 昂贵,必须避免重复生成。推荐架构:
text
POST /generate {url}
→ 查 store:相同 url 已有?
→ YES:直接返回缓存(0 token)
→ NO:调豆包生成 → 下载 mp3 到本地 → 写入 store → 返回
Store 数据模型:
json
{ "id": "gen_xxx", "source_url": "https://...", "title": "...", "audio_file": "gen_xxx.mp3", "duration_sec": 682 }
音频下载到本地后不再依赖豆包 CDN(24h 过期)。
指标值生成速度2.7× 实时(1 分钟播客 ≈ 22s 生成)首 tokenavg 6sToken 消耗~800 字/分钟播客,3000 token/分钟音频块4.6KB/chunk总测试量7 篇 + 线上多篇,全部成功
场景表现处理建连失败event=-1检查凭证,重试内容过滤错误码 50302102提示不支持,不重试心跳超时静默断开Python: ping_timeout=120audio_url 过期CDN 403用本地存储的 mp3
文件语言说明scripts/generate_podcast.pyPython批量生成 + CLI,POC 验证用项目 server/podcast-api.jsNode.js生产环境:HTTP API + SSE + 内容存储