AI 工具

ElevenLabs:把访谈或播客录音转成带说话人标注的字幕

这篇把访谈或播客录音转成带说话人标注的字幕。脚本调用 Scribe(POST /v1/speech-to-text,modelid=scribev2),开启说话人区分(diarize)、音频事件标注(tagaudioevents)和词级时间戳。 返回的 words 按说话人变化、标点、长度(18 字)、时长(6 秒)和停顿(0.8 秒)切成字幕条;笑声这类事

BINBHO · 图文教程

这篇把访谈或播客录音转成带说话人标注的字幕。脚本调用 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

先转写、再人工校对,访谈字幕就能比手工听写快得多。