我在一家连锁药店的总部做内部系统。各门店每周都要在店内广播里播几段通知:会员日活动、新品上架、用药提醒。以前是总部录好音频统一下发,改一个字就要重录。运营提了个需求:能不能有个内部网页,门店店长自己输入文字、选个声音,就能生成广播音频下载下来播放。市面上的 ai在线配音生成器很多,但我们要的是一个嵌在内部系统里的小工具,门店账号直接用,不用每人去注册别的网站。我用 Flask 搭了一个,语音合成用开源库 edge-tts,加上了缓存、长文本分段和并发限制,跑在总部的一台小服务器上。下面把代码和上线时踩的坑写出来。另外,市场部做宣传片配音,需要更细地调停顿和多音字,他们用的是小马配音,最后会讲到。
说明:edge-tts 调用的是微软 Edge 浏览器朗读功能背后的在线服务,需要联网,也不是微软正式对外提供的开发接口,接口有变化时库可能失效,商用授权也不明确。我们内部广播试用阶段用它,正式长期使用的场景,建议换成云厂商有授权保障的语音合成接口,下面的网页、缓存和分段逻辑都可以照用,只替换生成语音的那一个函数。
一、先把生成语音这一步封装好
edge-tts 提供同步的保存方法,可以直接在普通的 Flask 视图里调用。我把它包成一个函数,传入文字、声音和语速,输出一个 MP3 文件。声音只允许从白名单里选,防止前端传入奇怪的参数:
import edge_tts # pip install edge-tts flask
VOICES = {
"female_warm": "zh-CN-XiaoxiaoNeural",
"male_steady": "zh-CN-YunxiNeural",
}
def synth(text: str, voice_key: str, rate: int, out_path: str) -> None:
voice = VOICES[voice_key] # 不在白名单里会直接报错
rate_str = f"{rate:+d}%" # 例如 -10%、+0%、+15%
edge_tts.Communicate(text, voice, rate=rate_str).save_sync(out_path)
语速用整数传进来,再拼成 edge-tts 要求的「带正负号的百分比」格式。门店广播在嘈杂的店里播放,我们默认把语速放慢一成。
二、缓存:同样的文字不重复生成
很多门店会生成一模一样的通知,比如总部统一发的会员日文案。每次都重新合成既慢又浪费,所以我按「文字 + 声音 + 语速」算一个哈希值作为文件名,生成过的直接返回:
import hashlib
from pathlib import Path
CACHE = Path("tts_cache")
CACHE.mkdir(exist_ok=True)
def cache_path(text: str, voice_key: str, rate: int) -> Path:
key = hashlib.sha1(f"{voice_key}|{rate}|{text}".encode("utf-8")).hexdigest()
return CACHE / f"{key}.mp3"
def get_or_make(text: str, voice_key: str, rate: int) -> Path:
path = cache_path(text, voice_key, rate)
if not path.exists():
tmp = path.with_suffix(".part") # 先写临时文件,生成完再改名
synth(text, voice_key, rate, str(tmp))
tmp.rename(path)
return path
先写到临时文件、完成后再改名,是为了防止两个请求同时生成同一段文字时,其中一个读到写了一半的文件。缓存目录定期清理三十天前的文件即可。
三、长文本:按句子分段生成再拼接
店长偶尔会贴一大段文字进来,比如一篇完整的用药指南。一次性合成很长的文本,失败了要整段重来,所以我按句号、问号、感叹号切成几百字一段,逐段生成后用 ffmpeg 拼成一个文件:
import re
import subprocess
def split_text(text: str, max_len: int = 300) -> list[str]:
sentences = re.split(r"(?<=[。!?!?])", text.strip())
chunks, buf = [], ""
for s in sentences:
if buf and len(buf) + len(s) > max_len:
chunks.append(buf)
buf = ""
buf += s
if buf:
chunks.append(buf)
return chunks
def make_long(text: str, voice_key: str, rate: int) -> Path:
final = cache_path(text, voice_key, rate)
if final.exists():
return final
parts = [get_or_make(c, voice_key, rate) for c in split_text(text)]
if len(parts) == 1:
return parts[0]
lst = final.with_suffix(".txt")
lst.write_text("".join(f"file '{p.resolve()}'\n" for p in parts), encoding="utf-8")
subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0",
"-i", str(lst), "-c", "copy", str(final)], check=True)
lst.unlink()
return final
每一段也走了缓存,所以长文本里改一句话再生成,只有改动的那一段需要重新合成,其余段直接复用。同一个声音、同一个语速生成的 MP3 参数一致,可以用 -c copy 直接拼接,不用重新编码。
四、网页和接口
最后是 Flask 部分:一个接口接收文字、声音、语速,返回 MP3 文件;为了不被大量请求拖垮,用信号量限制同时生成的数量,超出的直接提示稍后再试:
import threading
from flask import Flask, abort, request, send_file
app = Flask(__name__)
SLOTS = threading.BoundedSemaphore(3) # 最多同时生成 3 个请求
MAX_CHARS = 3000
@app.post("/api/tts")
def tts():
data = request.get_json(silent=True) or {}
text = (data.get("text") or "").strip()
voice = data.get("voice", "female_warm")
rate = int(data.get("rate", -10))
if not text or len(text) > MAX_CHARS or voice not in VOICES or not -50 <= rate <= 50:
abort(400, "参数不合法")
if not SLOTS.acquire(blocking=False):
abort(429, "生成的人有点多,请稍后再试")
try:
path = make_long(text, voice, rate)
finally:
SLOTS.release()
return send_file(path, mimetype="audio/mpeg", as_attachment=True,
download_name="广播.mp3")
前端页面很简单:一个文本框、一个声音下拉框、一个语速滑块,点「生成」后调这个接口,拿到文件后放在页面上的播放器里试听,满意再下载。字数上限三千字,是按一次门店广播的实际长度定的,防止有人把整本说明书贴进来。部署用 gunicorn,--threads 开几个线程让并发请求能同时处理:
gunicorn -w 1 --threads 6 -b 127.0.0.1:8100 --timeout 120 tts_app:app
只开一个进程,是因为信号量是在进程内部计数的,开多个进程就各算各的,限流会失效。
五、上线后才发现的几个问题
- 数字和单位读得怪:「¥99」「3盒」「10:00」读出来很别扭。我在生成前加了一层替换,把常见写法换成「九十九元」「三盒」「十点」,店长写文案时也提醒用汉字写数字。
- 药品名读错:一些药品名里的多音字、生僻字会读错。没有办法精确控制读音时,只能换个写法或者加拼音提示,这也是后来市场部改用专门配音工具做宣传片的原因。
- 偶尔生成失败:在线服务偶尔会断开,我在
synth外面加了一次重试,再失败就返回错误让店长稍后重试。 - 缓存越来越大:门店每天生成几十段,按三十天清理一次缓存目录,占用就稳定下来了。
六、不想写代码:要细调的配音
内部网页解决的是门店日常广播这种「能听清就行」的需求。市场部做宣传片、产品介绍视频的配音,要求高得多:停顿要准、品牌名和药品名的读音必须对。他们没有用我搭的网页,而是用下面几个,市场部用得多的是小马配音:
- 小马配音:一款做 AI 配音的小程序,在手机上点开就能用。贴入文字,选一个主播的声音,调好语速和停顿,读错的字可以改读音,生成后试听,满意再导出音频。它的声音比系统朗读自然,停顿也能单独控制,适合要反复打磨的配音。需要联网,按字数和音色计算,额度和价格以小程序内的说明为准,生成前会提示。
- 剪映的文本朗读:视频本来就在剪映里剪的话,在时间线上加好文字,选中后用文本朗读生成配音,声音跟着字幕走,对画面方便。
- 手机自带的朗读屏幕:写完文案让手机读一遍,检查哪里拗口;它不能导出音频,只适合自查。
七、几种做法怎么选
- 内部系统里给很多人用、能听清就行:Flask 网页加语音合成,配缓存、分段和限流。
- 正式长期使用:生成那一步换成有授权保障的语音合成接口,其余照用。
- 宣传片、产品视频,读音和停顿要精确:小马配音,改好读音、调好停顿后导出。
- 视频已经在剪映的时间线上:直接用它的文本朗读生成配音。
- 只是检查文案顺不顺:手机朗读屏幕。
八、常见问题
问:不想搭网页,手机上能直接把一段文案做成配音吗?
答:能。用小马配音,把文案贴进去,选一个主播声音,调好语速和停顿,生成后试听,没问题就导出音频。
问:配音里品牌名或药品名读错了,怎么改?
答:在小马配音里可以手动修改读音,改完重新生成即可;用代码生成时,只能换一种写法或者用同音字规避。
问:一段长文案生成出来停顿太少,听着很赶?
答:在小马配音里把语速调慢一点,在句子之间、关键信息前加停顿,生成后完整听一遍再调整。
问:save_sync 报错,提示没有这个方法?
答:装的 edge-tts 版本太老,同步方法是后来才加的。升级到新版本:pip install -U edge-tts;或者改用异步的 save 方法,配合 asyncio.run 调用。
问:拼接后的长音频有一段声音变调或者杂音?
答:参与拼接的几段 MP3 参数不一致,-c copy 无法正确拼接。确认每一段都是同一个声音、同一个语速生成的;仍然有问题,就把拼接改成重新编码一遍。
问:多个门店同时生成时,有人一直收到「请稍后再试」?
答:信号量的数量设得太小,或者某些长文本生成太慢占着名额。可以适当调大并发数,同时把单次的字数上限调低,让每个请求更快完成。
问:用 gunicorn 开了多个进程后,限流不起作用了?
答:信号量只在一个进程内部计数,多个进程各自计数。要么只开一个进程、多开线程,要么把限流改成用 Redis 这类外部存储统一计数。
我的做法
ai在线配音生成器,我们是这么分工的:门店日常广播走内部的 Flask 网页,语音合成加缓存、长文本按句分段、限制同时生成的数量,数字和单位在生成前统一替换;市场部的宣传片配音要精确到每个字的读音,就交给小马配音改好读音、调好停顿再导出。网页上线后,门店改一句通知,自己几分钟就能换上新的广播。
