深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

给博客加上AI播客:用豆包TTS实现文章转双人对话语音

🎙️ 播客版
0:00 / 0:00

引言

上周末整理博客时突然想到一个问题:文章写得再长,读者真正能看完的也就前几百字。尤其是技术类文章,动辄几千字,很多人看到一半就划走了。如果能把文章转成语音,让访客在通勤、做家务的时候听,会不会是一个更好的内容消费方式?

抱着这个想法,我调研了一圈语音合成方案,最后选定了火山引擎的豆包播客TTS。它的特点是能生成双人对话形式的播客,两位AI主播一问一答地讨论文章内容,听起来比单调的单人朗读自然得多。这篇文章就记录一下完整的对接过程和踩坑经验。

给博客加上AI播客:用豆包TTS实现文章转双人对话语音

方案选型与核心思路

为什么选择豆包播客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: 4字节
# byte0: version(4bit) + header_size(4bit) = 0x11
# byte1: message_type(4bit) + flags(4bit)
# byte2: serial_method(4bit) + compression(4bit)
# byte3: reserved
header = bytes([0x11, (message_type << 4) | flags, 0x10, 0x00])

# Optional: event(4字节) + session_id长度(4字节) + session_id
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)

# Payload
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)

有几个关键点需要注意:

  1. 大端序:所有多字节字段都是大端序(>I 表示 big-endian unsigned int)。
  2. event字段:标志位 FLAG_WITH_EVENT = 0b0100 置位时,帧里才会带event字段。
  3. 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_idaccess_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数组,每个元素包含 speakertext。我一般会先打印出来看看效果,确认对话逻辑通顺后再进入下一步。

第二步:修正对话内容

豆包生成的对话有时候会缺少开场白,或者出现一些过于简短的过渡句。我做了一层后处理:

def modify_nlp_texts(nlp_texts, title=""):
# 检查前3轮是否有开场白
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_STARTPODCAST_ROUND_RESPONSE(音频数据)、PODCAST_ROUND_END。需要按顺序收集所有 PODCAST_ROUND_RESPONSE 里的payload,最后合并成完整MP3文件。

这里有个需要注意的地方:豆包API对每批对话的轮数有限制,单次最多25轮。如果文章较长导致对话超过25轮,需要分批生成,最后把多段MP3拼起来。MP3是流式格式,直接字节拼接即可,不需要重新编码。

文章文本的预处理

为了让播客效果更好,从Markdown提取文本时做了很多清洗:

def extract_article_text(file_path):
# 移除 front matter
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)

# 移除标题标记、粗体斜体、HTML标签
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好听很多。刘飞的音色沉稳,适合主讲;潇磊偏活泼,适合提问和补充。两个人的节奏感也很自然,不会有明显的”机器感”。唯一的小瑕疵是偶尔会出现重复表述,通过后处理过滤短句已经能缓解大部分问题。

踩过的坑

  1. WebSocket超时:生成长文章时容易超时,需要把 timeout 设大一点(60秒以上),同时增加重试机制。
  2. 二进制帧解析错误:一开始按小端序解析,结果payload长度全错。后来仔细看文档才发现是大端序。
  3. 对话轮次超限:单批超过25轮会直接报错,必须做分批处理。
  4. 音频拼接问题:分批生成的MP3不能直接拼,需要先去掉每段的ID3标签。实际上MP3流式拼接是可行的,但如果中间有完整的ID3v2头可能会出问题。我用的方法是确保只有第一批带开头音乐、最后一批带结尾音乐,中间批次不带音乐,这样拼接后是干净的。

结语

给博客加上AI播客这件事,本质上是在拓展内容的消费场景。不是所有人都愿意坐下来看一篇长文,但听一段10分钟的播客,门槛就低了很多。豆包播客TTS让这件事的实现成本变得很低,不需要真人录音、不需要剪辑,一行命令就能生成可用的音频内容。

如果你也有博客或者内容站点,不妨试试看。技术方案并不复杂,核心就是WebSocket二进制协议的解析和对话流程的编排。

不过也要提醒一点:AI播客适合作为辅助消费方式,不能完全替代文字阅读。有些技术细节、代码片段,听一遍很难记住,还是需要回到原文仔细看。最好的方式是在文章页同时提供文字和播客两种入口,让读者自己选择。

文章作者:阿文
文章链接: https://www.awen.me/post/55584.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
您的评论由 AI 智能审核,一般1分钟内会展示,若不展示请确认你的评论是否符合社区和法律规范
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️