配音按字符计费,长稿子很容易一次就用掉一大块额度。这篇写一个提交前预检脚本:先统计稿子里会被计费的字符数,再调用
GET /v1/user/subscription查询本期已用和上限,检查有没有超过模型的单次上限。 最后给出三种结论之一:OK、SPLIT(需要拆分)、STOP(额度不够)。如果是 STOP,脚本以退出码 1 结束,可以直接放进 CI 或批处理脚本里当一道闸门。
先看清成品(box 上对着模拟服务器运行)
- 示例稿件 → OK。
- 12000 字的稿子 → SPLIT(超过 Multilingual v2 的 1 万字上限)和 STOP,退出码 1。
- 真实接口用无效 Key → 400。
- 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"。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
budget.py |
统计计费字符、查询用量、检查模型上限,给出结论 |
narrate.py |
从 A 篇复制过来,--chunked 模式会用到它 |
fake_eleven.py、script.txt |
从 A 篇复制过来 |
test_budget.py |
测试计费口径和结论 |
需要准备哪些环境?
和 A 篇相同。
规则来源(官方文档和帮助中心)
- 计费口径:字母、数字、标点和空格都计入字符数,SSML 标签和音频标签也算在内;字符数会按模型换算成额度。
- 单次上限:Flash 和 Turbo v2.5 是 4 万字符,Flash 和 Turbo v2 是 3 万字符,Multilingual v2 是 1 万字符。
- 用量接口:
GET /v1/user/subscription返回character_count(本期已用)、character_limit(本期上限)和next_character_count_reset_unix(下次重置时间)。
文件结构
52-elevenlabs-B-src/
├── budget.py
├── narrate.py
├── fake_eleven.py
├── script.txt
└── test_budget.py
第 1 步:代码
"""配音前的额度预检:统计稿子会被计费的字符数,查询账户本期用量(GET /v1/user/subscription),
检查单次请求有没有超过模型上限,然后给出“可以提交 / 需要拆分 / 额度不够”的结论。
计费口径(官方帮助中心):字母、数字、标点、空格都算;SSML 标签和音频标签也算。
单次上限(官方帮助中心,API):Flash/Turbo v2.5 4 万,Flash/Turbo v2 3 万,Multilingual v2 1 万字符。"""
import argparse, datetime, os, pathlib, sys
import requests
API = os.environ.get("ELEVENLABS_API_BASE") or "https://api.elevenlabs.io"
MAX_CHARS = {"eleven_flash_v2_5": 40000, "eleven_turbo_v2_5": 40000, "eleven_flash_v2": 30000,
"eleven_turbo_v2": 30000, "eleven_multilingual_v2": 10000}
def billable(text):
return len(text.rstrip("\n")) # 每个字符都计费,包括空格、标点和标签;只去掉文件末尾的换行
def subscription():
r = requests.get(f"{API}/v1/user/subscription", timeout=30,
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 plan(n, model, sub, safety=0.1):
out = []
limit = MAX_CHARS.get(model)
if limit is None:
out.append(("WARN", f"未收录 {model} 的单次上限,请查官方文档"))
elif n > limit:
out.append(("SPLIT", f"{n} 字符超过 {model} 单次上限 {limit},至少拆成 {-(-n // limit)} 段"))
left = sub["character_limit"] - sub["character_count"]
need = int(n * (1 + safety)) # 预留 10% 给重试和修改
if need > left:
out.append(("STOP", f"本期剩余 {left},本次需要约 {need}(含 10% 余量),额度不够"))
return out or [("OK", f"本期剩余 {left},本次约 {need},可以提交")]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+"); ap.add_argument("--model", default="eleven_multilingual_v2")
ap.add_argument("--chunked", action="store_true", help="按 A 篇 narrate.py 的切段方式统计(实际发送的字符)")
a = ap.parse_args()
texts = [pathlib.Path(f).read_text(encoding="utf-8") for f in a.files]
if a.chunked:
from narrate import chunk
n = sum(len(p) for t in texts for p in chunk(t))
else:
n = sum(billable(t) for t in texts)
sub = subscription()
reset = datetime.datetime.fromtimestamp(sub["next_character_count_reset_unix"]).strftime("%Y-%m-%d %H:%M")
print(f"稿件 {len(a.files)} 份,计费字符 {n};本期已用 {sub['character_count']}/{sub['character_limit']},下次重置 {reset}(本机时区)")
res = plan(n, a.model, sub)
for lvl, msg in res:
print(f"[{lvl}] {msg}")
sys.exit(1 if any(l == "STOP" for l, _ in res) else 0)
if __name__ == "__main__":
main()
第 2 步:测试
from budget import billable, plan
SUB = {"character_count": 9000, "character_limit": 10000}
def test_billable_counts_spaces_and_tags():
assert billable("你好 世界!\n") == 6
assert billable('<break time="1s"/>好') == 19
def test_plan():
assert plan(500, "eleven_multilingual_v2", SUB)[0][0] == "OK"
assert plan(950, "eleven_multilingual_v2", SUB)[0][0] == "STOP" # 950*1.1 > 1000
r = plan(12000, "eleven_multilingual_v2", {"character_count": 0, "character_limit": 100000})
assert r[0][0] == "SPLIT"
assert plan(12000, "eleven_flash_v2_5", {"character_count": 0, "character_limit": 100000})[0][0] == "OK"
第 3 步:运行
$ python budget.py script.txt
稿件 1 份,计费字符 101;本期已用 9099/10000,下次重置 2026-11-02 00:00(本机时区)
[OK] 本期剩余 901,本次约 111,可以提交
$ python budget.py script.txt --chunked
稿件 1 份,计费字符 99;本期已用 9099/10000,下次重置 2026-11-02 00:00(本机时区)
$ python budget.py long.txt # 12000 个字
[SPLIT] 12000 字符超过 eleven_multilingual_v2 单次上限 10000,至少拆成 2 段
[STOP] 本期剩余 901,本次需要约 13200(含 10% 余量),额度不够
$ echo $?
1
$ ELEVENLABS_API_BASE= ELEVENLABS_API_KEY=sk_000…(51 位无效 Key) python budget.py script.txt # 真实接口
HTTP 400: {"detail":{"type":"authentication_error","code":"invalid_api_key","message":"API key is invalid.",…}}
“已用 9099”是因为模拟器在 A 篇运行时已经累加了 99 个字符。box 时区是 Asia/Shanghai(UTC+8),所以重置时间按北京时间显示。
调试示例
预检算出 101 个字符,实际只发送了 99 个。 script.txt 有三行,两个换行符也被算了进去。但 A 篇的 narrate.py 切段时会把换行去掉,真正发出去的只有 99 个字符。差两个字符看起来无所谓,可预检的意义就是“算的和发的是同一份文本”。所以加了 --chunked 选项,直接用 narrate.py 的切段函数来统计,结果和 A 篇的输出(99 字符)一致。默认模式按原文统计,结果偏保守。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 实际扣的比预估的多 | 发音词典替换、加了标签、或者重新生成过 | 预留余量(默认 10%) |
| SPLIT | 超过了模型的单次上限 | 用 A 篇的切段功能,或换成上限更高的模型 |
| 重置时间不对 | 时区理解错了 | 返回的是 Unix 时间戳,按本地时区换算 |
| 用量和后台显示不一致 | 后台以额度(credits)为单位显示 | 以后台为准,本脚本只用于提交前的拦截 |
模板规则
- 预检统计的文本必须和实际发送的完全一致。
- 额度不够就以非零退出码结束,阻止提交。
- 价格不写进代码,只比较字符数和额度。
项目资源
| 用途 | 地址 |
|---|---|
| ElevenLabs 文档索引(llms.txt) | https://elevenlabs.io/docs/llms.txt |
| Get user subscription | https://elevenlabs.io/docs/api-reference/user/subscription/get |
| 帮助:字符数是怎么计算的 | https://elevenlabs.io/docs/help-center/account/general/how-is-character-length-calculated |
把预检放在批量配音前面,就不会出现跑到一半额度用完的情况。