把一部几十万字的小说变成有声书,传统做法是配音员进棚录制、后期剪辑、逐章校对,一部作品往往要花掉数周时间。而现在借助文本预处理加语音合成技术,这套流程完全可以自动化:程序读入纯文本,自动切出章节,识别对话归属并分配不同音色,批量调用TTS接口合成音频,最后拼接输出成品。这篇文章就把整条流水线拆开讲清楚,给出可以直接落地的实现思路和代码。

一、章节自动分割:从纯文本到结构化数据
章节分割是整条流水线的第一步,也是最容易被低估的一步。中文网络小说的格式五花八门,有的用“第一章 XXX”,有的用“第1节”、还有的直接用数字编号,甚至混用全角和半角字符。如果只写一个简单的正则,遇到不规范文本就会漏切或者误切。
比较稳妥的做法是用一组正则模式做候选匹配,再结合上下文过滤误判。核心规则示例如下:
import re
# 常见章节标题模式
CHAPTER_PATTERNS = [
r'^#{0,3}\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章回节卷幕])\s*[::\s]?(.*)$',
r'^#{0,3}\s*(Chapter\s+\d+)\s*[::.\s]?(.*)$',
r'^#{0,3}\s*(序章|序言|楔子|尾声|后记|番外)\s*[::\s]?(.*)$',
]
def split_chapters(text):
lines = text.split('\n')
chapters = []
current_title, current_lines = None, []
for line in lines:
stripped = line.strip()
matched = None
for pat in CHAPTER_PATTERNS:
m = re.match(pat, stripped)
if m:
matched = m
break
# 章节标题行一般很短,超过30字的基本是正文误判
if matched and len(stripped) <= 30:
if current_title is not None:
chapters.append((current_title, '\n'.join(current_lines)))
current_title = stripped
current_lines = []
else:
current_lines.append(line)
if current_title is not None:
chapters.append((current_title, '\n'.join(current_lines)))
return chapters
这段代码的关键在于两个防御性设计:一是用字符类同时覆盖中文数字和阿拉伯数字,兼容不同排版习惯;二是限制标题行长度,避免正文里出现“第二天早上他想起第三章提到的事”这类句子被误判成章节标题。实际项目中还可以加一层校验:如果切出来的章节正文少于两百字,大概率是误切,应该和上一章合并。
切完章节后不要急着送TTS,先做一次文本清洗:去掉多余的空行、全角空格、HTML残留标签,把英文标点统一处理。TTS引擎对脏文本很敏感,一个奇怪的符号就可能导致整句读错或者直接报错。
二、多角色分配:让对话听得出“是谁在说话”
单音色的有声书听起来很枯燥,多角色分配能显著提升收听体验。实现思路是把正文切成叙述、对话两种片段,对话片段根据说话人分配不同音色,叙述部分用主讲人音色。
中文小说里对话通常用中文引号包裹,检测并不难,难点在于判断“这句话是谁说的”。一个实用的启发式规则是:看引号前后的叙述文字里有没有出现角色名加说、道、问、喊这类动词。示例如下:
import re
SPEECH_VERBS = {'说道', '说', '道', '问', '问道', '喊', '喊道', '回答', '笑道', '低声说'}
def extract_segments(paragraph, narrator='narrator'):
segments = []
# 按引号切分,保留引号内容
parts = re.split(r'(“[^”]*”)', paragraph)
buffer = ''
for part in parts:
if part.startswith('“') and part.endswith('”'):
# 引号前的叙述文字里找说话人
speaker = guess_speaker(buffer)
if speaker is None:
speaker = narrator
segments.append({'text': part, 'voice': voice_of(speaker)})
buffer = ''
else:
buffer += part
if part.strip():
segments.append({'text': part, 'voice': voice_of(narrator)})
return segments
def guess_speaker(context):
# 在引号前的文字里匹配 角色名+说话动词
for verb in SPEECH_VERBS:
m = re.search(r'([\u4e00-\u9fa5]{1,4})' + verb + r'[::,,]?\s*$', context)
if m:
return m.group(1)
return None
角色名到音色的映射可以用一个简单的配置字典维护:主角用沉稳的男声,女主角用女声,反派用低沉音色,没识别出说话人的对话默认归给上一个出现过的角色或主讲人。这种启发式方法准确率大概能到八成,剩余的部分需要人工抽查修正,建议把识别结果输出成带说话人标注的中间文件,方便编辑后再进入合成环节。
还有一个细节值得注意:旁白和对话之间切换时,最好在音频层面插入一小段静音,否则两个音色紧挨着播放会有明显的突兀感。这个可以在后期的音频拼接阶段统一处理。
三、批量TTS调用:并发、限流与断点续传
一章有声书可能有上百个片段,一部作品动辄几千个片段,串行调用TTS接口会慢到无法接受。但无脑开高并发又容易被服务商限流甚至封号,所以需要一个带并发上限和重试机制的调度器。
用Python的asyncio加信号量可以很优雅地实现这一点:
import asyncio
import aiofiles
import hashlib
import os
SEM = asyncio.Semaphore(5) # 同时最多5个请求,按服务商限额调整
async def tts_one(session, text, voice, out_dir):
# 用文本+音色做哈希,实现幂等和断点续传
key = hashlib.md5(f'{voice}|{text}'.encode()).hexdigest()
path = os.path.join(out_dir, key + '.mp3')
if os.path.exists(path):
return path # 已生成,直接跳过
payload = {'text': text, 'voice': voice, 'format': 'mp3'}
for attempt in range(3):
async with SEM:
try:
async with session.post('https://api.example-tts.ipipp.com/v1/synthesize',
json=payload) as resp:
if resp.status == 200:
data = await resp.read()
async with aiofiles.open(path, 'wb') as f:
await f.write(data)
return path
except Exception as e:
await asyncio.sleep(2 ** attempt) # 指数退避
raise RuntimeError(f'片段合成失败: {text[:20]}')
async def batch_tts(all_segments, out_dir):
os.makedirs(out_dir, exist_ok=True)
async def run():
tasks = [tts_one(s['session'], s['text'], s['voice'], out_dir)
for s in all_segments]
return await asyncio.gather(*tasks)
return await run()
这段代码有三个工程要点。第一,用文本和音色的哈希值做文件名,天然实现了断点续传:任务中途挂掉,重跑时已完成的片段会直接跳过,这对长时间批量任务来说至关重要。第二,信号量把并发压在服务商允许的范围内,配合指数退避的重试逻辑,可以平稳应对偶发的429限流响应。第三,建议在每次请求间加几十毫秒的随机抖动,避免并发请求形成规整的波峰。
本地部署TTS模型的话思路类似,只是瓶颈从网络变成了GPU显存,信号量的上限应该按显存能同时容纳的请求数来定,或者干脆用队列串行喂给模型,靠批量推理提升吞吐。
四、音频拼接与成品输出
所有片段合成完成后,需要按顺序拼接成完整的章节音频。推荐用pydub处理,简单直观:
from pydub import AudioSegment
def concat_chapter(files, output_path, gap_ms=300):
final = AudioSegment.silent(duration=500) # 开头留白
for f in files:
seg = AudioSegment.from_file(f)
final += seg
final += AudioSegment.silent(duration=gap_ms) # 片段间停顿
final += AudioSegment.silent(duration=800) # 结尾留白
final.export(output_path, format='mp3', bitrate='128k')
拼接时要注意所有片段的采样率必须一致,否则会出现变速变调的怪声。另外不同音色片段之间可以把停顿从三百毫秒加大到五百毫秒左右,让角色切换更自然。最后可以顺手生成一份章节时间戳清单,方便后续上传到有声书平台时填写章节起点。
整套方案跑通之后,一部三十万字的作品从原始文本到成品音频,普通配置的服务器几个小时就能处理完,相比人工录制效率提升是数量级的。后续还可以往两个方向优化:一是接入大模型做说话人识别,替代启发式规则提升角色分配准确率;二是建立音色试听和A/B机制,让每部作品都能快速选定最合适的配音组合。自动化流水线的价值就在于此,把重复劳动交给程序,人只负责最后的质量把关。