引言
上周末整理博客时突然想到一个问题:文章写得再长,读者真正能看完的也就前几百字。尤其是技术类文章,动辄几千字,很多人看到一半就划走了。如果能把文章转成语音,让访客在通勤、做家务的时候听,会不会是一个更好的内容消费方式?
抱着这个想法,我调研了一圈语音合成方案,最后选定了火山引擎的豆包播客TTS。它的特点是能生成双人对话形式的播客,两位AI主播一问一答地讨论文章内容,听起来比单调的单人朗读自然得多。这篇文章就记录一下完整的对接过程和踩坑经验。

方案选型与核心思路
为什么选择豆包播客TTS
市面上做TTS的厂商不少,我主要对比了这几家:
| 方案 |
特点 |
缺点 |
| 阿里云语音合成 |
稳定、价格便宜 |
只有单人朗读,无对话感 |
| 讯飞配音 |
情感丰富 |
按字符计费,长文成本高 |
| 豆包播客TTS |
双人对话、自然度高 |
只有WebSocket接口,集成稍复杂 |
最终选豆包的原因很简单:它能生成真正意义上的”播客”,而不是干巴巴的朗读。两位主播一个叫刘飞、一个叫潇磊,一个负责引导话题,一个负责提问和补充,听感非常接近真实的播客节目。
整体流程设计
整个功能的链路是这样的:
graph LR A[Markdown文章] --> B[提取纯文本] B --> C[调用豆包API生成对话文本] C --> D[修正开场白与冗余内容] D --> E[根据对话文本生成音频] E --> F[上传MP3到OSS] F --> G[将播客链接插入文章front matter]
|
整个流程封装在一个Python脚本里,执行 python3 tools/generate_podcast.py source/_posts/xxx.md 就能一键完成。
关键技术:WebSocket二进制协议解析
豆包播客TTS的接口是WebSocket,但它不是普通的JSON over WebSocket,而是自研的一套二进制帧协议。这也是对接过程中最麻烦的部分。
协议帧结构
每一帧由4部分组成:Header(4字节) + Optional(可选) + PayloadLength(4字节) + Payload。
def build_frame(message_type, event, session_id, payload_bytes, flags=0b0100): header = bytes([0x11, (message_type << 4) | flags, 0x10, 0x00])
optional = bytearray() if event is not None: optional.extend(struct.pack(">I", event)) if session_id is not None: sid_bytes = session_id.encode('utf-8') optional.extend(struct.pack(">I", len(sid_bytes))) optional.extend(sid_bytes)
frame = bytearray(header) frame.extend(optional) if payload_bytes is not None: frame.extend(struct.pack(">I", len(payload_bytes))) frame.extend(payload_bytes)
return bytes(frame)
|
有几个关键点需要注意:
- 大端序:所有多字节字段都是大端序(
>I 表示 big-endian unsigned int)。
- event字段:标志位
FLAG_WITH_EVENT = 0b0100 置位时,帧里才会带event字段。
- message_type:请求是
0b0001,音频响应是 0b1011,文本响应是 0b1001,错误是 0b1111。
连接与鉴权
WebSocket握手时需要携带鉴权头:
ws_headers = [ f"X-Api-App-Id: {app_id}", f"X-Api-Access-Key: {access_key}", f"X-Api-Resource-Id: volc.service_type.10050", f"X-Api-App-Key: aGjiRDfUWi", f"X-Api-Request-Id: {uuid.uuid4()}" ]
ws = websocket.create_connection( "wss://openspeech.bytedance.com/api/v3/sami/podcasttts", timeout=60, header=ws_headers )
|
这里 app_id 和 access_key 需要在火山引擎控制台创建应用后获取。
三步走:从文本到音频
整个生成流程分三步,每步对应一个action参数。
第一步:生成对话文本(action=0)
先让AI根据文章内容生成播客对话脚本。这里有个技巧:加上 only_nlp_text=True,API会直接返回每轮对话的文本,而不是音频。
payload = { "input_text": text, "action": 0, "input_info": { "only_nlp_text": True, "input_text_max_length": 12000 }, "speaker_info": { "random_order": True, "speakers": [ "zh_male_liufei_v2_saturn_bigtts", "zh_male_xiaolei_v2_saturn_bigtts" ] } }
|
返回的数据格式是JSON数组,每个元素包含 speaker 和 text。我一般会先打印出来看看效果,确认对话逻辑通顺后再进入下一步。
第二步:修正对话内容
豆包生成的对话有时候会缺少开场白,或者出现一些过于简短的过渡句。我做了一层后处理:
def modify_nlp_texts(nlp_texts, title=""): has_opening = any("欢迎收听" in t["text"] or "我是阿文" in t["text"] for t in nlp_texts[:3])
if not has_opening: first_speaker = nlp_texts[0]["speaker"] opening = f"欢迎收听阿文的播客,我是阿文。今天想跟大家聊聊这篇文章——《{title}》。" nlp_texts.insert(0, {"speaker": first_speaker, "text": opening})
filtered = [item for item in nlp_texts if len(item["text"].strip()) >= 10]
return filtered
|
这里强制要求播客以”欢迎收听阿文的播客,我是阿文……”开头,增强品牌感。
第三步:生成音频(action=3)
拿到修正后的对话文本后,再次调用WebSocket,这次 action=3,带上完整的对话列表:
payload = { "action": 3, "nlp_texts": nlp_texts, "use_head_music": True, "use_tail_music": True, "audio_config": { "format": "mp3", "sample_rate": 24000, "speech_rate": 0 }, "speaker_info": { "random_order": True, "speakers": ["zh_male_liufei_v2_saturn_bigtts", "zh_male_xiaolei_v2_saturn_bigtts"] } }
|
API返回的是MP3格式的二进制音频数据。每轮对话会触发三个event:PODCAST_ROUND_START、PODCAST_ROUND_RESPONSE(音频数据)、PODCAST_ROUND_END。需要按顺序收集所有 PODCAST_ROUND_RESPONSE 里的payload,最后合并成完整MP3文件。
这里有个需要注意的地方:豆包API对每批对话的轮数有限制,单次最多25轮。如果文章较长导致对话超过25轮,需要分批生成,最后把多段MP3拼起来。MP3是流式格式,直接字节拼接即可,不需要重新编码。
文章文本的预处理
为了让播客效果更好,从Markdown提取文本时做了很多清洗:
def extract_article_text(file_path): content = re.sub(r'^---\s*\n.*?\n---\s*\n', '', content, flags=re.DOTALL)
content = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', '', content) content = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', content) content = re.sub(r'```.*?```', '', content, flags=re.DOTALL) content = re.sub(r'`[^`]+`', '', content)
content = re.sub(r'^#+\s*', '', content, flags=re.MULTILINE) content = re.sub(r'[*_]{1,2}', '', content) content = re.sub(r'<[^>]+>', '', content)
return content.strip()
|
另外,为了让AI更好地生成播客,我在文章文本前面加了一段引导语(prompt),明确告诉AI两位主播要以第三人称视角讨论文章,不能代入作者本人。这个prompt对最终效果影响很大,直接决定了播客是”讨论”还是”朗读”。
完整使用流程
脚本写好后,日常使用非常简单:
python3 tools/generate_podcast.py source/_posts/2026/04/文章标题.md
|
执行后控制台会输出每一步的进度:
📝 处理文章: source/_posts/2026/04/文章标题.md ============================================================
📄 提取文章文本... 提取了 3850 字符
📝 步骤1: 生成播客对话文本... ✅ 生成 18 轮对话
📋 生成的播客对话文本: [刘飞] 欢迎收听阿文的播客,我是阿文。今天想跟大家聊聊这篇文章——《给博客加上AI播客》... [潇磊] 作者这个想法挺有意思,把文章转成播客,确实能解决很多人没时间看长文的问题...
🔧 步骤2: 检查并修改对话文本...
🎙️ 步骤3: 根据对话文本生成音频... 🎵 开头音乐... 🎙️ 播客轮次 0: 刘飞 🎙️ 播客轮次 1: 潇磊 ⏱️ 本轮时长: 12.3秒 ...
☁️ 上传音频到OSS... ⬆️ 上传: oss://file201503/podcast/2026/04/24/20260424a1b2c3d4.mp3
✅ 播客上传成功: https://file.awen.me/podcast/2026/04/24/20260424a1b2c3d4.mp3
📝 插入播客链接到文章... ✅ 已在 front matter 中添加 podcast 字段
============================================================ 🎉 完成!
|
生成的播客链接会被写入文章的front matter:podcast: https://file.awen.me/podcast/.../xxx.mp3。前端主题里加一个播客播放器组件,读取这个字段即可在文章页展示。
成本与效果
目前火山引擎豆包播客TTS还没有公开计费文档(处于内测阶段),从实际测试来看,生成一篇3000字文章的播客大约需要30-60秒,音频时长约8-12分钟。按火山的计费标准推算,成本大概在几分钱到一毛钱之间,对于个人博客来说完全可以接受。
效果方面,豆包的双人对话模式确实比单人TTS好听很多。刘飞的音色沉稳,适合主讲;潇磊偏活泼,适合提问和补充。两个人的节奏感也很自然,不会有明显的”机器感”。唯一的小瑕疵是偶尔会出现重复表述,通过后处理过滤短句已经能缓解大部分问题。
踩过的坑
- WebSocket超时:生成长文章时容易超时,需要把
timeout 设大一点(60秒以上),同时增加重试机制。
- 二进制帧解析错误:一开始按小端序解析,结果payload长度全错。后来仔细看文档才发现是大端序。
- 对话轮次超限:单批超过25轮会直接报错,必须做分批处理。
- 音频拼接问题:分批生成的MP3不能直接拼,需要先去掉每段的ID3标签。实际上MP3流式拼接是可行的,但如果中间有完整的ID3v2头可能会出问题。我用的方法是确保只有第一批带开头音乐、最后一批带结尾音乐,中间批次不带音乐,这样拼接后是干净的。
结语
给博客加上AI播客这件事,本质上是在拓展内容的消费场景。不是所有人都愿意坐下来看一篇长文,但听一段10分钟的播客,门槛就低了很多。豆包播客TTS让这件事的实现成本变得很低,不需要真人录音、不需要剪辑,一行命令就能生成可用的音频内容。
如果你也有博客或者内容站点,不妨试试看。技术方案并不复杂,核心就是WebSocket二进制协议的解析和对话流程的编排。
不过也要提醒一点:AI播客适合作为辅助消费方式,不能完全替代文字阅读。有些技术细节、代码片段,听一遍很难记住,还是需要回到原文仔细看。最好的方式是在文章页同时提供文字和播客两种入口,让读者自己选择。
评论
0 条评论