适用场景
手上有一集 40–90 分钟的双人对谈或圆桌录音,需要尽快拿到一份能直接发出去的文稿:谁在什么时候说了什么、每句话能定位到音频时间点、人名公司和产品名不被写错。
适合播客与内容团队、做访谈整理的研究咨询岗,以及需要给音频库建检索索引的 AI 工程师。不适合涉密或必须完全离线的音频——这条链路依赖云端 API,那类场景应该换成本地推理方案。
环境与前置条件
- 操作系统:macOS、常见 Linux 发行版,或 Windows 下的 WSL2。下面命令按 Unix 风格书写。
- 运行时:Python 3.9 及以上(具体下限以官方 SDK 文档为准);ffmpeg 与 ffprobe 必须可用。
- 硬件:转写在云端完成,本地不需要 GPU。内存 8 GB 足够同时跑两三个任务;磁盘按 1 小时 128 kbps 的 mp3 约 55–60 MB 估算,切片和中间产物按 3 倍预留。若保留 48 kHz 立体声 wav 原始文件,1 小时约 1 GB,注意留余量。
- 账号与配额:一个 ElevenLabs API key,且套餐包含语音转文字额度。单文件时长与大小上限、并发限制以官方文档和账号用量页为准。
- 网络:能出网访问 API 域名,公司网络需放行。
分步骤部署
第 1 步:建目录、装虚拟环境
```bash
mkdir -p ~/scribe-podcast/{in,work,out,terms,logs}
cd ~/scribe-podcast
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install requests
```
这一步只是隔离依赖,避免污染系统 Python。成功标志:which python 指向 ~/scribe-podcast/.venv/bin/python。
第 2 步:安装 ffmpeg 并确认版本
```bash
macOS
brew install ffmpeg
Debian / Ubuntu
sudo apt-get update && sudo apt-get install -y ffmpeg
ffmpeg -version
ffprobe -version
```
只要两条命令都能打印出版本行即可。如果系统里装的是精简版、缺 libmp3lame 编码器,后面转 mp3 会失败,直接重装完整版。
第 3 步:配置 API Key(不要写进脚本)
```bash
printf '%s' 'YOUR_API_KEY' > ~/scribe-podcast/.key
chmod 600 ~/scribe-podcast/.key
export ELEVENLABS_API_KEY="$(cat ~/scribe-podcast/.key | tr -d '[:space:]')"
echo "key 长度: ${#ELEVENLABS_API_KEY}"
```
这里用 printf '%s' 而不是 echo,是因为 echo 会把换行一起写进文件,而带换行的 key 在请求头里会直接触发 401。长度值和自己 key 的实际字符数一致,就说明加载正确。
第 4 步:音频预处理
先看时长和码率,决定是否需要切片:
```bash
ffprobe -v error -show_entries format=duration,size,bit_rate \
-of default=nw=1 in/ep01.m4a
```
统一转成单声道 16 kHz、64 kbps 的 mp3。口语识别够用,体积小、上传快:
```bash
ffmpeg -y -i in/ep01.m4a -ac 1 -ar 16000 -b:a 64k work/ep01.mp3
```
两端音量差很大时做一次响度归一化(可选):
```bash
ffmpeg -y -i work/ep01.mp3 -af "loudnorm=I=-16:TP=-1.5:LRA=11" work/ep01_norm.mp3
```
超过单文件限制时切片。优先切在掌声、音乐过场这类自然边界,而不是机械按分钟切,否则接缝处会把句子劈成两半:
```bash
20 分钟一片的兜底做法
ffmpeg -y -i work/ep01.mp3 -f segment -segment_time 1200 -c copy work/ep01_part_%03d.mp3
```
可以先扫一遍静音点,挑切点:
```bash
ffmpeg -i work/ep01.mp3 -af silencedetect=noise=-35dB:d=1.5 -f null - 2>&1 | grep silence
```
第 5 步:写转写脚本
新建 transcribe.py。模型标识和请求参数名都集中在顶部配置区——这部分以官方文档当前版本为准,如果字段名有变化,只改这一处:
```python
#!/usr/bin/env python3
"""单文件转写:说话人分离 + 逐词时间戳 + 术语表"""
import argparse, json, os, sys, time, pathlib
import requests
API_BASE = os.environ.get("ELEVENLABS_API_BASE", "https://api.elevenlabs.io/v1")
API_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
MODEL_ID = os.environ.get("SCRIBE_MODEL_ID", "")
参数名与取值以官方文档为准,只在这里改
BASE_PARAMS = {
"diarize": "true", # 说话人分离
"timestamps_granularity": "word", # 逐词时间戳
"tag_audio_events": "true", # 标注笑声、掌声等
}
def load_terms(path, limit=80):
if not path:
return []
seen, out = set(), []
for line in pathlib.Path(path).read_text(encoding="utf-8").splitlines():
t = line.strip()
if not t or t.startswith("#") or t in seen:
continue
seen.add(t)
out.append(t)
return out[:limit]
def transcribe(path, terms, speakers=None, lang=None, retries=4):
data = dict(BASE_PARAMS)
if speakers:
data["num_speakers"] = str(speakers)
if lang:
data["language_code"] = lang
if terms:
data["keyterms"] = json.dumps(terms, ensure_ascii=False) # 格式以官方文档为准
for i in range(retries):
with open(path, "rb") as f:
files = {"file": (pathlib.Path(path).name, f, "audio/mpeg")}
r = requests.post(f"{API_BASE}/speech-to-text",
headers={"xi-api-key": API_KEY},
data=data, files=files, timeout=3600)
if r.status_code in (429, 500, 502, 503, 504):
wait = 5 * (2 ** i)
print(f" {r.status_code} 重试 {i+1}/{retries},等 {wait}s", file=sys.stderr)
time.sleep(wait)
continue
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code}: {r.text[:400]}")
return r.json()
raise RuntimeError("重试次数用尽")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("audio")
ap.add_argument("--out", required=True)
ap.add_argument("--terms")
ap.add_argument("--speakers", type=int)
ap.add_argument("--lang")
a = ap.parse_args()
if not API_KEY:
sys.exit("缺少环境变量 ELEVENLABS_API_KEY")
if not MODEL_ID:
sys.exit("缺少环境变量 SCRIBE_MODEL_ID,请按官方文档填写模型标识")
out = pathlib.Path(a.out)
if out.exists():
print(f"SKIP {out} 已存在") # 幂等:重跑不会重复计费
return
t0 = time.time()
res = transcribe(a.audio, load_terms(a.terms), a.speakers, a.lang)
out.parent.mkdir(parents=True, exist_ok=True)
tmp = out.with_suffix(out.suffix + ".tmp")
tmp.write_text(json.dumps(res, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, out) # 先写临时文件再改名,中断不会留半截 JSON
words = res.get("words") or []
spk = {w.get("speaker_id") for w in words if w.get("speaker_id")}
print(f"OK {a.audio} -> {len(words)} words, speakers={len(spk)}, {time.time()-t0:.1f}s")
if __name__ == "__main__":
main()
```
跑一次:
```bash
export SCRIBE_MODEL_ID="<官方文档里 Scribe 对应的模型标识>"
python transcribe.py work/ep01.mp3 \
--out out/ep01.json --terms terms/tech.txt --speakers 2 --lang zh
```
成功时终端最后一行形如 OK work/ep01.mp3 -> 9873 words, speakers=2, 214.6s,并且 out/ep01.json 里有 words 数组,每个元素带 text、start、end、speaker_id。
第 6 步:配置术语表
术语表解决的是"人名人称被写成别的字"这类问题:嘉宾叫「张珂」被写成「张科」,产品叫「NebulaGraph」被拆成三个词,缩写「RAG」被写成「拉格」。
怎么攒词条:
- 来源:往期人工校对过的文稿、本期节目大纲、嘉宾简介、产品名清单。最省事的做法是把上一集定稿丢给模型抽专有名词,人工过一遍。
- 写法:一条一个词,写成"你最终希望在文稿里看到的形态"。中英混说的场景,英文原形必须单独成条。
- 数量:一般几十条效果最好,数量上限与单条长度限制以官方文档为准。塞几百条会稀释每条权重,反而没效果。
- 别放常见词:「模型」「公司」「平台」这类词放进去,会把正常语句带偏。
- 同义写法只留一个:术语表是纠偏器,不是同义词表。
terms/tech.txt:
```
一行一条,# 开头是注释
ElevenLabs
Scribe
NebulaGraph
AI 词典:向量数据库">向量数据库
张珂
RAG
```
验证方式很直接:同一段音频跑两遍,一次带 --terms、一次不带,diff 两份文稿,只看专有名词段落。
第 7 步:导出 SRT/VTT 并做分段校对
to_srt.py:按"说话人变化 / 停顿超过 0.8 秒 / 长度超限 / 单条超 8 秒"切字幕,并支持套用说话人映射表:
```python
#!/usr/bin/env python3
import json, sys, pathlib
def fmt(t):
ms = int(round(t * 1000))
h, ms = divmod(ms, 3600000)
m, ms = divmod(ms, 60000)
s, ms = divmod(ms, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def build(words, mapping, max_chars=42, max_gap=0.8, max_dur=8.0):
cues, cur = [], []
for w in words:
if not w.get("text", "").strip():
continue
spk = mapping.get(w.get("speaker_id"), w.get("speaker_id")) or ""
if cur:
p = cur[-1]
pspk = mapping.get(p.get("speaker_id"), p.get("speaker_id")) or ""
too_long = len("".join(x["text"] for x in cur)) >= max_chars
gap = w.get("start", 0.0) - p.get("end", 0.0)
if spk != pspk or gap > max_gap or too_long or \
w.get("end", 0.0) - cur[0]["start"] > max_dur:
cues.append(cur); cur = []
cur.append(w)
if cur:
cues.append(cur)
return cues
def main():
src = pathlib.Path(sys.argv[1])
dst = pathlib.Path(sys.argv[2]) if len(sys.argv) > 2 else src.with_suffix(".srt")
mapping = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
words = json.loads(src.read_text(encoding="utf-8")).get("words") or []
lines, idx = [], 0
for cue in build(words, mapping):
idx += 1
spk = mapping.get(cue[0].get("speaker_id"), cue[0].get("speaker_id")) or ""
text = "".join(w["text"] for w in cue).strip()
if spk:
text = f"[{spk}] {text}"
lines.append(f"{idx}\n{fmt(cue[0]['start'])} --> {fmt(cue[-1]['end'])}\n{text}\n")
dst.write_text("\n".join(lines), encoding="utf-8")
print(f"写出 {dst},共 {idx} 条字幕")
if __name__ == "__main__":
main()
```
```bash
python to_srt.py out/ep01.json out/ep01.srt '{"speaker_0":"主播","speaker_1":"嘉宾"}'
```
中文逐词拼接可能出现粘连,如果发现"我们就开始吧"变成"我们就开始吧"没问题、但英文单词黏在一起,把 "".join(...) 换成按字符类型补空格的拼接即可。
分段校对的三条纪律:
1. 只改三类问题:术语错拼、说话人标反、明显漏句。逐句润色是另一个工序,混在一起会让校对时间翻倍。
2. 切片之间 speaker 编号不保证一致。ep01_part_000 里的 speaker_0 可能是主播,ep01_part_001 里可能变成嘉宾。所以不要全局替换,而是给每一片单独写映射表,导出时按片套用——上面脚本的第三个参数就是干这个的。
3. 用时间戳定位复听。看到可疑句子,记下 SRT 里的时间码,播放器跳过去听 5 秒,比反复读文稿快得多。每发现一个新错拼,立刻追加到 terms/tech.txt,下次批量跑自动受益。
第 8 步:批量处理
```bash
cd ~/scribe-podcast
source .venv/bin/activate
export ELEVENLABS_API_KEY="$(cat .key | tr -d '[:space:]')"
export SCRIBE_MODEL_ID="<官方文档里 Scribe 的模型标识>"
for f in work/*.mp3; do
base="$(basename "$f" .mp3)"
echo "[$(date '+%F %T')] START $base" | tee -a logs/batch.log
python transcribe.py "$f" --out "out/$base.json" \
--terms terms/tech.txt --speakers 2 --lang zh 2>&1 | tee -a logs/batch.log
done
```
要点三个:
- 幂等:脚本里
out.exists()直接跳过,任务中断后重跑不会重复计费。 - 并发:需要并行时用
ls work/*.mp3 | xargs -P 2 -I{} ...,-P从 2 起试。并发调高很容易撞上 429。 - 失败清单:循环结束后
grep -v '^OK' logs/batch.log,把失败的单独重跑。
验证部署是否成功
按顺序跑四条:
```bash
ffmpeg -version | head -n 1
python -c "import requests; print(requests.__version__)"
echo ${#ELEVENLABS_API_KEY}
python -c "import json;d=json.load(open('out/ep01.json'));print(len(d.get('words',[])))"
```
预期:第一条打印 ffmpeg 版本行,第二条打印 requests 版本号,第三条打印的字符数和 key 实际长度一致,第四条打印一个大于 0 的整数。
再用一小段 30 秒测试音频做结构自检:
```bash
python - <<'PY'
import json
d = json.load(open("out/ep01.json"))
w = d.get("words") or []
assert w, "words 为空,检查是否开启了逐词时间戳"
mono = all(w[i]["start"] <= w[i+1]["start"] + 1e-6 for i in range(len(w)-1))
print("词数:", len(w))
print("时间戳单调递增:", mono)
print("首词:", w[0]["text"].strip(), round(w[0]["start"], 2))
print("末词:", w[-1]["text"].strip(), round(w[-1]["end"], 2))
print("说话人标签:", sorted({x.get("speaker_id") for x in w if x.get("speaker_id")}))
PY
```
预期输出 时间戳单调递增: True,首词时间接近 0,末词时间接近音频总时长,说话人标签数量等于实际参与人数(双人对谈就是 2 个)。
常见报错与解决
1. 401 {"detail":{"status":"invalid_api_key"}}
→ key 没导出、被复制时带了首尾空格,或文件里混进了换行符。
→ 重新写文件并校验:
```bash
printf '%s' 'YOUR_API_KEY' > ~/scribe-podcast/.key
export ELEVENLABS_API_KEY="$(cat ~/scribe-podcast/.key | tr -d '[:space:]')"
echo ${#ELEVENLABS_API_KEY} # 与 key 实际长度一致
```
2. 413 / Request Entity Too Large / 上传到一半连接中断
→ 文件超过单次上传上限,或网络抖动。
→ 转低码率并切片,逐片上传:
```bash
ffmpeg -y -i in/ep01.wav -ac 1 -ar 16000 -b:a 64k work/ep01.mp3
ffmpeg -y -i work/ep01.mp3 -f segment -segment_time 1200 -c copy work/ep01_part_%03d.mp3
```
3. ffmpeg: command not found 或 Unknown encoder 'libmp3lame'
→ ffmpeg 未安装,或装的是缺编码器的精简构建。
→ 重装完整版:
```bash
sudo apt-get install -y --reinstall ffmpeg
ffmpeg -encoders | grep -i mp3 # 应能看到 libmp3lame
```
4. 整集只识别出一个说话人,或标签频繁跳变
→ 立体声混成单声道后音色过于接近、两人抢话重叠、背景音乐太响。
→ 降低或去掉 BGM;用 --speakers 明确人数;条件允许时把主持人和嘉宾的原始分轨分别转写,再按时间戳合并——分轨转写的说话人准确率通常明显更高。
5. 429 Too Many Requests
→ 并发过高。脚本已内置指数退避,把 /work/*.mp3 的并行度降到 1–2,或把批处理放到夜间跑。
6. 422 参数不被识别
→ 字段名大小写、布尔值传成了字符串、或模型标识写错。所有这类参数都集中在 BASE_PARAMS 和 MODEL_ID 两处,对照官方文档改一处即可,不要散落在代码里。
7. 术语表里明明有,文稿还是写错
→ 词条数量过多稀释权重、单条太短(比如只写「张」)、或中文语境下没给英文原形。
→ 精简到几十条,每条做一次单独验证:用一段包含该词的 20 秒音频测试,命中后再放进正式表。
后续维护
备份:原始音频、切片、原始 JSON 三层都保留。原始 JSON 尤其不能删——术语表更新、字幕样式调整、换导出格式,都可以基于它重跑后处理,不必重新计费转写。
术语表版本化:把 terms/ 放进 git,文件名带日期(如 tech-2025Q1.txt),每期录制前 review 一次。术语表是长期资产,积累到一两百条后,专有名词返工量会显著下降。
升级策略:模型标识、参数名、返回结构都可能随版本变化。升级前用同一集固定素材做 A/B:对比专有名词命中率、说话人正确率、时间戳漂移量、耗时与费用四项,全都持平或更好再切过去。
日志与监控:批处理记录每个文件的 status / 耗时 / 词数 / 说话人数,失败清单单独落盘,重跑只跑失败项。每周扫一眼 429 和 5xx 的比例,异常升高通常是并发配置或账号配额的问题。
成本与配额:上传前先切掉长静音段和片头片尾音乐;内部试跑用低码率副本;每月对着账号用量页核一次实际消耗。
密钥轮换:按季度更换 key,旧 key 在控制台吊销。.key 文件权限保持 600,并确认它没有被误提交进 git(加进 .gitignore)。
