跳到主内容
快讯直播
AI智模界
教程

ElevenLabs 播客转写实战:说话人、时间戳与术语表

适用场景

手上有一集 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 词典:向量数据库">向量数据库

LoRA

张珂

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)。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。