这篇把一篇长稿子做成一条完整的配音和一份对齐的字幕。脚本会先按句子把稿子切成若干段,每段不超过
--limit个字符,然后逐段调用with-timestamps接口。调用时带上previous_text和next_text,让段与段之间的语气连贯。 每段返回的音频解码后,用 ffprobe 测出真实时长来累加时间偏移;字幕按字符级时间戳和标点切条,最后用 ffmpeg 把各段拼成narration.mp3,同时生成narration.srt。
先看清成品(box 上对着模拟服务器运行)
- 99 个字符切成 4 段,拼出 14.99 秒的音频和 11 条字幕。
- 模拟器的日志证实:中间两段的 previous_text 和 next_text 都带上了。
- pytest 2 项通过。
合规提醒:
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"。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
narrate.py |
切段 → TTS → 解码 → 计算时间偏移 → 生成 SRT → 拼接 |
fake_eleven.py |
模拟服务器(127.0.0.1:18521),B 篇和 C 篇也会用到 |
script.txt |
示例稿件 |
test_narrate.py |
测试切段和字幕时间计算 |
需要准备哪些环境?
| 项目 | 要求 |
|---|---|
| Python | 3.9 以上,pip install requests flask pytest |
| ffmpeg | 用来拼接音频,以及用 ffprobe 测时长 |
| 真实使用 | 在 ElevenLabs 后台创建 API Key;voice_id 从 GET /v1/voices 或语音库里查 |
接口要点(来自官方文档)
- 地址:
POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/with-timestamps,请求头xi-api-key。 - 请求参数:
- 查询参数
output_format,默认mp3_44100_128。 - 请求体包含
text、model_id(默认eleven_multilingual_v2)、previous_text、next_text、voice_settings等。
- 查询参数
- 返回:
audio_base64,以及alignment和normalized_alignment。这两项都包含characters、character_start_times_seconds和character_end_times_seconds。 - 单次长度上限(官方帮助中心,API 调用):Multilingual v2 是 1 万字符,Flash 和 Turbo v2.5 是 4 万字符。
文件结构
52-elevenlabs-A-src/
├── narrate.py
├── fake_eleven.py
├── script.txt
└── test_narrate.py
第 1 步:代码
"""长文配音 + 字幕:把稿子按句子切成若干段,逐段调用 ElevenLabs
POST /v1/text-to-speech/{voice_id}/with-timestamps(传 previous_text / next_text 让段与段衔接自然),
解码 audio_base64,按每段音频的真实时长累加偏移,用字符级时间戳生成 SRT,最后用 ffmpeg 拼接成一个 mp3。"""
import argparse, base64, json, os, pathlib, re, subprocess
import requests
API = os.environ.get("ELEVENLABS_API_BASE") or "https://api.elevenlabs.io"
UA = "binbho-tutorial-elevenlabs/1.0"
def split_sentences(text):
return [s for s in re.findall(r"[^。!?!?;;\n]+[。!?!?;;]?", text) if s.strip()]
def chunk(text, limit=800):
"""按句子聚成段,每段不超过 limit 个字符;单句超长时按逗号再切。"""
out, cur = [], ""
for s in split_sentences(text):
parts = [s] if len(s) <= limit else re.findall(r"[^,,]+[,,]?", s)
parts = [q[i:i + limit] for q in parts for i in range(0, len(q), limit)] # 没有逗号的超长句:硬切
for p in parts:
if len(cur) + len(p) > limit and cur:
out.append(cur); cur = ""
cur += p.strip()
if cur: out.append(cur)
return out
def duration(path):
r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
capture_output=True, text=True, check=True)
return float(r.stdout)
def tts(s, voice, text, prev, nxt, model):
r = s.post(f"{API}/v1/text-to-speech/{voice}/with-timestamps", params={"output_format": "mp3_44100_128"},
json={"text": text, "model_id": model, "previous_text": prev or None, "next_text": nxt or None}, timeout=180)
if r.status_code >= 400:
raise SystemExit(f"HTTP {r.status_code}: {r.text[:300]}")
return r.json()
def srt_time(t):
ms = int(round(t * 1000)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
return f"{h:02}:{m:02}:{s:02},{ms:03}"
def cues_from_alignment(al, offset, max_chars=18):
"""把字符级时间戳按标点和长度切成字幕条。"""
cues, buf, st = [], "", None
for ch, a, b in zip(al["characters"], al["character_start_times_seconds"], al["character_end_times_seconds"]):
if st is None and ch.strip(): st = a
buf += ch
if ch in "。!?!?;;,," or len(buf) >= max_chars:
if buf.strip(): cues.append((st + offset, b + offset, buf.strip(",, ")))
buf, st = "", None
if buf.strip(): cues.append((st + offset, al["character_end_times_seconds"][-1] + offset, buf.strip()))
return cues
def main():
ap = argparse.ArgumentParser()
ap.add_argument("script"); ap.add_argument("--voice", required=True)
ap.add_argument("--model", default="eleven_multilingual_v2"); ap.add_argument("--limit", type=int, default=800)
ap.add_argument("--out", default="outputs")
a = ap.parse_args()
out = pathlib.Path(a.out); out.mkdir(exist_ok=True)
s = requests.Session(); s.headers.update({"xi-api-key": os.environ["ELEVENLABS_API_KEY"], "User-Agent": UA})
parts = chunk(pathlib.Path(a.script).read_text(encoding="utf-8"), a.limit)
print(f"共 {len(parts)} 段,{sum(map(len, parts))} 字符")
files, cues, offset = [], [], 0.0
for i, p in enumerate(parts):
d = tts(s, a.voice, p, parts[i - 1] if i else "", parts[i + 1] if i + 1 < len(parts) else "", a.model)
f = out / f"part_{i:03}.mp3"; f.write_bytes(base64.b64decode(d["audio_base64"]))
cues += cues_from_alignment(d["alignment"], offset)
dur = duration(f)
print(f" 段 {i}: {len(p)} 字符,{dur:.2f}s(时间戳最后一个字结束于 {d['alignment']['character_end_times_seconds'][-1]:.2f}s)")
offset += dur; files.append(f)
(out / "list.txt").write_text("".join(f"file '{f.name}'\n" for f in files))
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", str(out / "list.txt"),
"-c", "copy", str(out / "narration.mp3")], check=True)
(out / "narration.srt").write_text("\n".join(f"{n}\n{srt_time(a_)} --> {srt_time(b_)}\n{t}\n" for n, (a_, b_, t) in enumerate(cues, 1)),
encoding="utf-8")
print(f"✓ {out/'narration.mp3'} {duration(out/'narration.mp3'):.2f}s,字幕 {len(cues)} 条")
if __name__ == "__main__":
main()
第 2 步:模拟服务器
"""本地模拟服务器(自己写的,不是 ElevenLabs 的):
- POST /v1/text-to-speech/<voice>/with-timestamps:用 ffmpeg 生成一段正弦波 mp3 代替人声,每个字 0.15 秒,返回字符级时间戳
- GET /v1/user/subscription:返回用量
- POST /v1/speech-to-text:返回预置的词级结果"""
import base64, os, subprocess, tempfile
from flask import Flask, request, jsonify
app = Flask(__name__)
app.json.ensure_ascii = False
PER = 0.15
USAGE = {"character_count": int(os.environ.get("FAKE_USED", "9000")), "character_limit": 10000}
LOG = []
def auth():
k = request.headers.get("xi-api-key", "")
if k != "sk_" + "d" * 48:
return jsonify(detail={"type": "authentication_error", "code": "invalid_api_key", "message": "(模拟) Invalid API key"}), 401
@app.post("/v1/text-to-speech/<voice>/with-timestamps")
def tts(voice):
if (e := auth()): return e
b = request.get_json()
text = b["text"]
LOG.append({"voice": voice, "chars": len(text), "previous_text": bool(b.get("previous_text")), "next_text": bool(b.get("next_text"))})
USAGE["character_count"] += len(text)
dur = PER * len(text)
with tempfile.NamedTemporaryFile(suffix=".mp3") as f:
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", f"sine=f=330:d={dur}:sample_rate=44100",
"-ac", "1", "-c:a", "libmp3lame", "-b:a", "128k", f.name], check=True)
audio = base64.b64encode(open(f.name, "rb").read()).decode()
al = {"characters": list(text), "character_start_times_seconds": [round(i * PER, 3) for i in range(len(text))],
"character_end_times_seconds": [round((i + 1) * PER, 3) for i in range(len(text))]}
return jsonify(audio_base64=audio, alignment=al, normalized_alignment=al)
@app.get("/v1/user/subscription")
def sub():
if (e := auth()): return e
return jsonify(tier="mock", character_count=USAGE["character_count"], character_limit=USAGE["character_limit"],
next_character_count_reset_unix=1793548800, status="active")
@app.post("/v1/speech-to-text")
def stt():
if (e := auth()): return e
if request.form.get("model_id") != "scribe_v2" or "file" not in request.files:
return jsonify(detail=[{"loc": ["body", "model_id"], "msg": "field required", "type": "missing"}]), 422
words, t = [], 0.0
script = [("speaker_0", "大家好,欢迎收听本期节目。"), ("speaker_1", "今天我们聊聊秋天的夜市。"),
("speaker_0", "嗯,那我们开始吧!")]
for spk, sent in script:
for ch in sent:
words.append({"text": ch, "type": "word", "start": round(t, 2), "end": round(t + 0.2, 2), "speaker_id": spk, "logprob": -0.1})
t += 0.2
words.append({"text": " ", "type": "spacing", "start": round(t, 2), "end": round(t + 0.6, 2), "speaker_id": spk, "logprob": 0})
t += 0.6
words.insert(5, {"text": "(笑声)", "type": "audio_event", "start": 1.0, "end": 1.4, "speaker_id": "speaker_1", "logprob": -0.5})
return jsonify(language_code="zho", language_probability=0.99, text="".join(s for _, s in script), words=words)
@app.get("/_log")
def log():
return jsonify(LOG)
if __name__ == "__main__":
app.run("127.0.0.1", 18521, threaded=True)
第 3 步:测试
import narrate
def test_chunk_respects_limit_and_keeps_text():
t = "第一句。第二句很长," + "很" * 50 + "长。第三句!"
parts = narrate.chunk(t, 20)
assert all(len(p) <= 20 for p in parts) and "".join(parts) == t.replace(" ", "")
def test_cues_offset_and_srt_time():
al = {"characters": list("你好,世界。"), "character_start_times_seconds": [0, .1, .2, .3, .4, .5],
"character_end_times_seconds": [.1, .2, .3, .4, .5, .6]}
c = narrate.cues_from_alignment(al, 10.0)
assert c == [(10.0, 10.3, "你好"), (10.3, 10.6, "世界。")]
assert narrate.srt_time(3723.456) == "01:02:03,456"
第 4 步:运行
$ python fake_eleven.py &
$ export ELEVENLABS_API_BASE=http://127.0.0.1:18521 ELEVENLABS_API_KEY=sk_ddd…(模拟器认的 51 位假 Key)
$ python narrate.py script.txt --voice demoVoice --limit 40
共 4 段,99 字符
段 0: 29 字符,4.39s(时间戳最后一个字结束于 4.35s)
段 1: 25 字符,3.79s(时间戳最后一个字结束于 3.75s)
段 2: 25 字符,3.79s(时间戳最后一个字结束于 3.75s)
段 3: 20 字符,3.03s(时间戳最后一个字结束于 3.00s)
✓ outputs/narration.mp3 14.99s,字幕 11 条
$ head -12 outputs/narration.srt
1
00:00:00,000 --> 00:00:01,350
欢迎来到秋日夜市。
2
00:00:01,350 --> 00:00:02,400
天色刚暗下来
3
00:00:02,400 --> 00:00:04,350
摊位上的灯一盏接一盏亮起。
$ ELEVENLABS_API_BASE= ELEVENLABS_API_KEY=sk_000…(51 位无效 Key) python narrate.py script.txt --voice demoVoice # 真实接口
共 1 段,99 字符
HTTP 400: {"detail":{"type":"authentication_error","code":"invalid_api_key","message":"API key is invalid.","status":"invalid_api_key",…}}
检查点:
- 拼接后的总时长(14.99 秒)等于各段 ffprobe 时长之和,最后一条字幕结束在 14.964 秒。
- 真实使用时
--limit可以设大一些(默认 800)。段数越少,接缝越少。
调试示例
1. 超长句子没有被切开。 第一版遇到超长句时只按逗号再切一次。测试里放了一个 50 个“很”字、中间没有逗号的句子,切出来的段还是超过了上限,测试失败。现在的做法是:按逗号切完之后,仍然超长的部分按 limit 直接硬切。
2. 为什么用 ffprobe 测出的时长,而不用时间戳的结束时间? 看上面的运行输出:每段 mp3 的实际时长都比“最后一个字结束的时间”长 0.03 到 0.04 秒,这是 mp3 编码器在首尾补的静音。拼接后,这些静音会原样保留下来(总长 14.99 秒)。如果用时间戳的结束时间来累加偏移,每一段字幕都会比声音提前约 0.04 秒;稿子切成 100 段时,累计误差就有 4 秒左右。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
400 invalid_api_key |
Key 写错,或已经被删除 | 到后台重新复制 |
| 段与段之间语气不连贯 | 没有传上下文 | 带上 previous_text 和 next_text(本文的做法) |
| 字幕越到后面越超前 | 时间偏移用了时间戳的结束时间 | 改用每段音频的实测时长 |
| 422 | 请求体的字段写错了 | 对照文档检查字段名 |
模板规则
- 按句子切段,传上下文,切段规则要有测试。
- 时间偏移以实测的音频时长为准。
- 把字幕和音频一起交付,并标注“AI 合成语音”。
项目资源
| 用途 | 地址 |
|---|---|
| Create speech with timing(带时间戳的 TTS) | https://elevenlabs.io/docs/api-reference/text-to-speech/convert-with-timestamps |
| ElevenLabs 文档索引(llms.txt) | https://elevenlabs.io/docs/llms.txt |
| Create speech(普通 TTS) | https://elevenlabs.io/docs/api-reference/text-to-speech/convert |
| 帮助:单次最多能合成多少字符 | https://elevenlabs.io/docs/help-center/product/core-capabilities/text-to-speech/whats-the-maximum-amount-of-characters-and-text-i-can-generate |
配音和字幕可以直接导入剪辑软件,下一步用 B 篇在提交前先算好额度。