这篇把访谈或播客录音转成带说话人标注的字幕。脚本调用 Scribe(
POST /v1/speech-to-text,model_id=scribe_v2),开启说话人区分(diarize)、音频事件标注(tag_audio_events)和词级时间戳。 返回的 words 按说话人变化、标点、长度(18 字)、时长(6 秒)和停顿(0.8 秒)切成字幕条;笑声这类事件单独成条,不打断正在说的那句话。最后输出 SRT 字幕和原始 JSON。
先看清成品(box 上对着模拟服务器运行)
- 38 个 token 生成 6 条字幕,说话人按
--names显示为“主持人”和“嘉宾”。- 笑声单独成一条,“欢迎收听本期节目”没有被拆开。
- pytest 3 项通过。
合规提醒:
ELEVENLABS_API_KEY只放在服务器端。克隆或使用他人的声音,必须取得本人明确授权;不得冒充真人,也不得用于诈骗或误导。对话录音转写前,要告知并取得参与者的同意,转写结果属于个人信息,要妥善保管。在中国境内发布合成语音,要遵守《生成式人工智能服务管理暂行办法》《互联网信息服务深度合成管理规定》,并按《人工智能生成合成内容标识办法》加上显式或隐式的 AI 标识。ElevenLabs 按字符或额度计费,本文不列价格。
运行说明:box 上没有 ElevenLabs API Key,所以没有真正合成或识别过语音。流程跑在我自己写的模拟服务器
fake_eleven.py上:它用 ffmpeg 生成正弦波来代替人声,时间戳和转写结果也是模拟器按规则造的。用错误的 Key 请求真实接口确实运行过:格式正确但无效的 51 位 Key 返回400 invalid_api_key "API key is invalid.",过短的 Key 在 TTS 接口上返回401 "Invalid API key"。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
stt_srt.py |
转写 → 切条 → 输出 SRT |
sample.mp3 |
A 篇生成的模拟音频(作为上传样本) |
fake_eleven.py |
从 A 篇复制过来 |
test_stt.py |
测试按说话人、标点、停顿切条,事件处理,乱序输入 |
需要准备哪些环境?
和 A 篇相同。
接口要点(来自官方文档)
- 地址:
POST https://api.elevenlabs.io/v1/speech-to-text,multipart 格式。 - 请求参数:
model_id必填(文档示例用的是scribe_v2)。- 音频二选一:上传
file(不超过 5GB,至少 100ms),或者给source_url。 - 可选:
diarize、num_speakers(最多 32)、tag_audio_events、timestamps_granularity(word或character)、language_code。
- 返回:
words[],每项包含text、type(word、spacing或audio_event)、start、end、speaker_id和logprob。
文件结构
52-elevenlabs-C-src/
├── stt_srt.py
├── sample.mp3
├── fake_eleven.py
└── test_stt.py
第 1 步:代码
"""音频转带说话人的字幕:POST /v1/speech-to-text(model_id=scribe_v2、diarize、tag_audio_events、
timestamps_granularity=word),把返回的 words 按说话人、标点、长度和停顿切成字幕条,写成 SRT 和逐字稿。"""
import argparse, json, os, pathlib
import requests
API = os.environ.get("ELEVENLABS_API_BASE") or "https://api.elevenlabs.io"
PUNCT = "。!?!?;;,,"
def transcribe(path, num_speakers=None, language=None):
data = {"model_id": "scribe_v2", "diarize": "true", "tag_audio_events": "true", "timestamps_granularity": "word"}
if num_speakers: data["num_speakers"] = str(num_speakers)
if language: data["language_code"] = language
with open(path, "rb") as f:
r = requests.post(f"{API}/v1/speech-to-text", data=data, files={"file": (pathlib.Path(path).name, f)}, timeout=600,
headers={"xi-api-key": os.environ["ELEVENLABS_API_KEY"], "User-Agent": "binbho-tutorial-elevenlabs/1.0"})
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:300]}")
return r.json()
def cues(words, max_chars=18, max_dur=6.0, gap=0.8, names=None, events=True):
names = names or {}
out, cur, evs = [], None, []
def flush():
nonlocal cur
if cur and cur["text"].strip():
out.append(cur)
cur = None
for w in sorted((w for w in words if w.get("start") is not None), key=lambda w: w["start"]):
if w["type"] == "audio_event":
if events: # 事件单独成条,不打断正在说的那句话(允许和台词时间重叠)
evs.append({"start": w["start"], "end": w["end"], "spk": None, "text": f"[{w['text'].strip('()()')}]"})
continue
if w["type"] == "spacing":
if cur and w["end"] - w["start"] >= gap:
flush()
elif cur:
cur["text"] += " " if w["text"].strip() == "" and cur["text"][-1:].isascii() else ""
continue
spk = names.get(w.get("speaker_id"), w.get("speaker_id"))
if cur and (spk != cur["spk"] or len(cur["text"]) >= max_chars or w["end"] - cur["start"] > max_dur):
flush()
if cur is None:
cur = {"start": w["start"], "end": w["end"], "spk": spk, "text": ""}
cur["text"] += w["text"]; cur["end"] = w["end"]
if w["text"][-1:] in PUNCT:
flush()
flush()
return sorted(out + evs, key=lambda c: c["start"])
def t(s):
ms = int(round(s * 1000)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); sec, ms = divmod(ms, 1000)
return f"{h:02}:{m:02}:{sec:02},{ms:03}"
def to_srt(cs):
lines = []
for i, c in enumerate(cs, 1):
txt = c["text"].strip(",, ")
lines.append(f"{i}\n{t(c['start'])} --> {t(c['end'])}\n" + (f"{c['spk']}:{txt}" if c["spk"] else txt) + "\n")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("audio"); ap.add_argument("--speakers", type=int); ap.add_argument("--lang")
ap.add_argument("--names", default="", help="如 speaker_0=主持人,speaker_1=嘉宾")
ap.add_argument("--no-events", action="store_true"); ap.add_argument("--out", default="outputs")
a = ap.parse_args()
names = dict(x.split("=", 1) for x in a.names.split(",") if "=" in x)
d = transcribe(a.audio, a.speakers, a.lang)
out = pathlib.Path(a.out); out.mkdir(exist_ok=True)
(out / "raw.json").write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8")
cs = cues(d["words"], names=names, events=not a.no_events)
(out / "subs.srt").write_text(to_srt(cs), encoding="utf-8")
print(f"语言 {d.get('language_code')}(置信度 {d.get('language_probability')}),{len(d['words'])} 个 token → {len(cs)} 条字幕")
print(to_srt(cs))
if __name__ == "__main__":
main()
第 2 步:测试
from stt_srt import cues, to_srt
def W(text, s, e, spk="speaker_0", typ="word"):
return {"text": text, "start": s, "end": e, "speaker_id": spk, "type": typ}
def test_split_by_speaker_punct_and_gap():
ws = [W("你", 0, .2), W("好", .2, .4), W("。", .4, .5), W("在", .6, .8, "speaker_1"), W("吗", .8, 1, "speaker_1"),
W(" ", 1, 2, "speaker_1", "spacing"), W("在", 2, 2.2, "speaker_1")]
cs = cues(ws, names={"speaker_0": "A", "speaker_1": "B"})
assert [(c["spk"], c["text"]) for c in cs] == [("A", "你好。"), ("B", "在吗"), ("B", "在")]
def test_events_and_unsorted_input():
ws = [W("好", 1.0, 1.2), W("(laughter)", .5, .9, typ="audio_event"), W("你", .8, 1.0)]
srt = to_srt(cues(ws))
assert srt.index("[laughter]") < srt.index("你好")
def test_event_does_not_split_sentence():
ws = [W("欢", .8, 1.0), W("(笑声)", 1.0, 1.4, typ="audio_event"), W("迎", 1.0, 1.2), W("。", 1.2, 1.3)]
cs = cues(ws)
assert [c["text"] for c in cs] == ["欢迎。", "[笑声]"]
第 3 步:运行
$ python stt_srt.py sample.mp3 --names speaker_0=主持人,speaker_1=嘉宾
语言 zho(置信度 0.99),38 个 token → 6 条字幕
1
00:00:00,000 --> 00:00:00,800
主持人:大家好
2
00:00:00,800 --> 00:00:02,600
主持人:欢迎收听本期节目。
3
00:00:01,000 --> 00:00:01,400
[笑声]
4
00:00:03,200 --> 00:00:05,600
嘉宾:今天我们聊聊秋天的夜市。
5
00:00:06,200 --> 00:00:06,600
主持人:嗯
6
00:00:06,600 --> 00:00:08,000
主持人:那我们开始吧!
$ ELEVENLABS_API_BASE= ELEVENLABS_API_KEY=sk_000…(51 位无效 Key) python stt_srt.py sample.mp3 # 真实接口
HTTP 400: {"detail":{"type":"authentication_error","code":"invalid_api_key","message":"API key is invalid.",…}}
检查点:
- 第 3 条(笑声)和第 2 条在时间上有重叠,这是有意的。大多数播放器会把两条叠在一起同时显示。
- 不想显示事件,可以加
--no-events。
调试示例
1. 笑声把一句话切成了两半。 第一版遇到 audio_event 时,会先结束当前字幕再插入事件,结果字幕变成了“主持人:欢”“[笑声]”“主持人:迎收听本期节目。”三条。现在事件单独收集,最后按开始时间排序后再并入,正在说的那句话保持完整。test_event_does_not_split_sentence 测试了这种情况。
2. 测试失败的原因在测试本身。 test_split_by_speaker_punct_and_gap 第一次运行失败,切出了 ('B','在'), ('A','吗')。原因是测试数据里,“吗”这个字没有指定说话人,用了默认的 speaker_0。改正测试数据后就通过了。这也说明按说话人切条的逻辑是灵敏的:只要说话人变了,就一定会切开。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 说话人编号乱跳 | 说话人数量判断错了 | 传 --speakers 2(即 num_speakers) |
| 中文词与词之间多出了空格 | 把 spacing 当成文字拼了进去 | 只在英文之间补空格(代码已处理) |
| 字幕条太长 | 长时间没有停顿或标点 | 调小 max_chars 或 max_dur |
| 422 | 缺少 model_id,或者没有上传文件 | model_id 和 file 都必须给 |
模板规则
- 转写之前先取得录音参与者的同意。
- 按说话人、标点、长度和停顿切条,事件单独成条。
- 保存原始 JSON,便于以后重新切条。
项目资源
| 用途 | 地址 |
|---|---|
| Speech to Text 接口 | https://elevenlabs.io/docs/api-reference/speech-to-text/convert |
| Speech to Text 能力说明 | https://elevenlabs.io/docs/overview/capabilities/speech-to-text |
| ElevenLabs 文档索引(llms.txt) | https://elevenlabs.io/docs/llms.txt |
先转写、再人工校对,访谈字幕就能比手工听写快得多。