SSML 可以控制停顿、语速、音高和读法,但手写容易出错:忘了转义
&就是 400,停顿加多了会被悄悄丢掉。 这篇做ssml_build.py:把带简单标记的讲稿(# 标题、[停 1.5s]、[[STEM|stem]])转成 SSML,并按官方规则在本地检查:转义、XML 合法性、计费字符数、单个停顿和整篇停顿总长。
先看清成品
- 示例讲稿生成 SSML:计费字符 204(含标签的原始长度 442),和独立的“去标签 + 解码实体”计算结果一致。
- 发现真实问题:20 个小节的讲稿停顿合计 35 秒,超过 30 秒的部分会被丢弃,且接口不报错。
- 加
--split:拆成 2 份(停顿 28 秒 + 7 秒),逐份请求。pytest 6 项通过。
合规提醒:只朗读你有权使用的文字;把别人的书或文章转成音频再传播可能侵犯版权。不要用 TTS 冒充真人(声音克隆需要本人同意,本文不涉及)。本文使用 Speechify 开发者 API(api.speechify.ai),和朗读 App(speechify.com)是两个不同产品。key 在 platform.speechify.ai 创建,放在环境变量 SPEECHIFY_API_KEY 中。按字符计费,价格以官网为准(本文不列价格)。
运行说明:box 上没有 Speechify key。我用无效 key 真实调用了
POST https://api.speechify.ai/v1/audio/speech(2026-10-21 20:35 和 20:50,UTC+8),得到 HTTP 401{"error":{"code":"unauthorized","message":"Unauthorized"},"request_id":"…"}。其余响应来自我写的本地替身:字段名按官方 API 参考,音频是 ffmpeg 生成的正弦波。没有真正合成过语音。 本篇只在本地生成和检查 SSML,不调用接口。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
ssml_build.py |
讲稿 → SSML + 检查 + 拆分 |
script.md / long_script.md |
示例讲稿(我写的) |
test_ssml.py |
6 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,只用标准库;测试需要 pytest |
关键事实
| 事实 | 来源 |
|---|---|
| 必须转义 & < > " ';XML 不合法(标签不配对、未声明实体)返回 400 | 官方 SSML 文档 |
| 拼错的属性名或属性值被忽略而不是报错,仍返回 200 | 同上 |
| break:strength 从 none(0ms)到 x-strong(1250ms);time 单个最多 10 秒;整篇插入的停顿最多 30 秒,超出部分被丢弃 | 同上 |
| simba-3.x 应用 break、prosody(rate/pitch/volume)、sub alias;emphasis 被接受但不生效 | 同上 |
| 计费字符不算标签;实体算 1 个字符;sub 按 alias 计 | 官方 API 限制 |
文件结构
78-speechify-B-src/
├── ssml_build.py
├── script.md
├── long_script.md
└── test_ssml.py
第 1 步:脚本
"""把带简单标记的讲稿转成 Speechify SSML,并按官方 SSML 文档在本地检查:
- 转义 & < > " ';XML 不合法会被接口拒绝(400),所以先用 xml.etree 解析一遍
- 计费字符 = 去掉标签、实体算 1 个字符、<sub> 按 alias 计(官方 API Limits)
- 单个 break 最多 10s;整篇插入的停顿合计最多 30s,超出部分会被丢弃(不报错)
讲稿标记:# 标题(读慢一点、前后停顿);[停 1.5s];[[词|读法]] 生成 <sub alias>"""
import argparse, html, json, re, sys
import xml.etree.ElementTree as ET
from pathlib import Path
def esc(t):
return t.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'")
PAUSE = re.compile(r"\[停\s*([\d.]+)\s*s\]"); SUB = re.compile(r"\[\[([^|\]]+)\|([^\]]+)\]\]")
def inline(text):
out, pos = [], 0
for m in re.finditer(f"{PAUSE.pattern}|{SUB.pattern}", text):
out.append(esc(text[pos:m.start()]))
if m.group(1): out.append(f'<break time="{float(m.group(1)) * 1000:.0f}ms"/>')
else: out.append(f'<sub alias="{esc(m.group(3))}">{esc(m.group(2))}</sub>')
pos = m.end()
out.append(esc(text[pos:])); return "".join(out)
def blocks(src):
parts = []
for block in [b.strip() for b in src.split("\n\n") if b.strip()]:
if block.startswith("#"):
parts.append(f'<break strength="strong"/><prosody rate="slow">{inline(block.lstrip("# ").strip())}</prosody><break strength="medium"/>')
else:
parts.append(inline(" ".join(block.split())))
return parts
def build(src):
return "<speak>" + "\n".join(blocks(src)) + "</speak>"
def build_docs(src, max_pause_s=28, max_chars=2000):
"""拆成多份 SSML,每份停顿 < 30s(留余量)且计费字符 ≤ 2000,逐份请求后再拼接音频。"""
docs, cur = [], []
for b in blocks(src):
trial = "<speak>" + "\n".join(cur + [b]) + "</speak>"
info = analyze(trial)
if cur and (info["pause_s"] > max_pause_s or info["billable"] > max_chars):
docs.append("<speak>" + "\n".join(cur) + "</speak>"); cur = [b]
else:
cur.append(b)
if cur: docs.append("<speak>" + "\n".join(cur) + "</speak>")
return docs
STRENGTH = {"none": 0, "x-weak": 250, "weak": 500, "medium": 750, "strong": 1000, "x-strong": 1250}
def ms(v):
m = re.fullmatch(r"([\d.]+)(ms|s)", v.strip())
return float(m.group(1)) * (1 if m.group(2) == "ms" else 1000) if m else 0
def analyze(ssml):
root = ET.fromstring(ssml) # 不合法会抛 ParseError
problems, total, spoken = [], 0.0, []
def walk(el):
nonlocal total
if el.tag == "break":
d = ms(el.get("time")) if el.get("time") else STRENGTH.get(el.get("strength", "medium"), 750)
if d > 10000: problems.append(f"单个停顿 {d / 1000:g}s 超过 10s 上限")
total += min(d, 10000)
if el.tag == "emphasis": problems.append("<emphasis> 在 simba-3.x 上被接受但不生效")
if el.tag == "sub":
spoken.append(el.get("alias", ""))
else:
spoken.append(el.text or "")
for c in el: walk(c)
if el is not root: spoken.append(el.tail or "")
walk(root)
if total > 30000: problems.append(f"停顿合计 {total / 1000:g}s,超过 30s 的部分会被丢弃")
return {"billable": len("".join(spoken)), "raw": len(ssml), "pause_s": total / 1000, "problems": problems}
def main(argv=None):
ap = argparse.ArgumentParser(); ap.add_argument("script"); ap.add_argument("-o", default="script.ssml")
ap.add_argument("--split", action="store_true", help="超过停顿或字符上限时拆成多份:script-1.ssml, script-2.ssml…")
a = ap.parse_args(argv)
if a.split:
docs = build_docs(Path(a.script).read_text(encoding="utf-8")); stem = Path(a.o).with_suffix("")
for i, d in enumerate(docs, 1):
info = analyze(d); Path(f"{stem}-{i}.ssml").write_text(d, encoding="utf-8")
print(f"{stem}-{i}.ssml:计费字符 {info['billable']},停顿 {info['pause_s']:g}s" + "".join(";" + p for p in info["problems"]))
return
ssml = build(Path(a.script).read_text(encoding="utf-8"))
try: info = analyze(ssml)
except ET.ParseError as e: sys.exit(f"生成的 SSML 不是合法 XML(接口会返回 400):{e}")
Path(a.o).write_text(ssml, encoding="utf-8")
print(ssml); print(f"\n计费字符 {info['billable']}(原始 {info['raw']},上限 2000 / 原始 20000),停顿合计 {info['pause_s']:g}s")
for p in info["problems"]: print("提醒:" + p)
if info["billable"] > 2000: print("超过 /v1/audio/speech 的 2000 字符:拆分或改用 /v1/audio/stream")
if __name__ == "__main__":
main()
第 2 步:示例讲稿
# Welcome to the Riverside Library
Opening hours are 9 a.m. to 6 p.m. [停 0.5s] Members can borrow up to 5 books & 2 DVDs.
# Children's corner
Story time is on Saturdays. Ask staff about the [[STEM|stem]] club for ages 8 < 12.
第 3 步:测试
import xml.etree.ElementTree as ET, pytest
import ssml_build as m
def test_escape_and_valid_xml():
s = m.build("Tom & Jerry say \"5 < 6\" isn't > 8")
ET.fromstring(s)
assert "&" in s and "<" in s and "'" in s and """ in s
def test_billable_counts_alias_and_entities():
info = m.analyze(m.build("A & [[STEM|stem club]]"))
assert info["billable"] == len("A & stem club")
def test_pause_markers_and_limits():
info = m.analyze(m.build("a [停 12s] b"))
assert '超过 10s' in info["problems"][0] and info["pause_s"] == 10
def test_emphasis_warning():
assert any("emphasis" in p for p in m.analyze("<speak><emphasis>hi</emphasis></speak>")["problems"])
def test_split_keeps_each_doc_under_30s():
src = "\n\n".join(f"# S{i}\n\nBody {i}." for i in range(20))
assert m.analyze(m.build(src))["pause_s"] > 30
docs = m.build_docs(src)
assert len(docs) >= 2 and all(m.analyze(d)["pause_s"] <= 28 for d in docs)
assert "".join(docs).count("Body") == 20
def test_malformed_raises():
with pytest.raises(ET.ParseError):
m.analyze("<speak><prosody rate='slow'>x</speak>")
第 4 步:运行
$ python ssml_build.py script.md
<speak><break strength="strong"/><prosody rate="slow">Welcome to the Riverside Library</prosody><break strength="medium"/>
Opening hours are 9 a.m. to 6 p.m. <break time="500ms"/> Members can borrow up to 5 books & 2 DVDs.
<break strength="strong"/><prosody rate="slow">Children's corner</prosody><break strength="medium"/>
Story time is on Saturdays. Ask staff about the <sub alias="stem">STEM</sub> club for ages 8 < 12.</speak>
计费字符 204(原始 442,上限 2000 / 原始 20000),停顿合计 4s
$ python ssml_build.py long_script.md -o long.ssml | tail -2
计费字符 961(原始 2636,上限 2000 / 原始 20000),停顿合计 35s
提醒:停顿合计 35s,超过 30s 的部分会被丢弃
$ python ssml_build.py long_script.md -o long.ssml --split
long-1.ssml:计费字符 765,停顿 28s
long-2.ssml:计费字符 195,停顿 7s
$ python -m pytest -q
6 passed
怎么检查结果?
- 计费字符我用另一种方法核对过:去掉所有标签、把 sub 换成 alias、再解码实体,结果同为 204。
- 拼错的属性不会报错(官方明确说明),所以检查脚本输出的 SSML 时要特别留意属性拼写。
- 拆分时留了 2 秒余量(每份不超过 28 秒停顿)。
调试示例
停顿被悄悄丢掉。 我用一个 20 小节的讲稿测试(每个标题前后各有 strong 1000ms + medium 750ms 的停顿):
$ python ssml_build.py long_script.md -o long.ssml | tail -2
计费字符 961(原始 2636,上限 2000 / 原始 20000),停顿合计 35s
提醒:停顿合计 35s,超过 30s 的部分会被丢弃
官方文档写明:整篇 SSML 插入的停顿最多 30 秒,超出的部分直接丢弃,不会被拒绝。也就是说最后两三个小节的标题停顿会消失,而接口照样返回 200,听的时候才会发现节奏变了。
修复:加 --split,按小节累加,停顿将超过 28 秒或计费字符将超过 2000 时开始新的一份。修复后得到 long-1.ssml(28 秒)和 long-2.ssml(7 秒),分别请求后再按 A 篇的方法拼接。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 400 | XML 不合法 | 用脚本生成,不手写;先本地解析 |
| 标签没效果 | 属性拼错,或 emphasis 在 simba-3.x 不生效 | 检查拼写;改用 prosody |
| 计费比预期多 | sub 按 alias 计 | alias 尽量简短 |
模板规则
- SSML 用程序生成并在本地解析。
- 记得算停顿总长。
- 只用当前模型会应用的标签。
- 计费字符按官方规则计算。
项目资源
| 用途 | 地址 |
|---|---|
| Speechify API 文档(官方) | https://docs.speechify.ai |
| Create Speech 接口参考(官方) | https://docs.speechify.ai/build/api-reference/v1/audio/speech |
| API 限制(官方) | https://docs.speechify.ai/build/guides/concepts/api-limits |
| Speechify 开发者平台 | https://platform.speechify.ai |
| SSML 文档(官方) | https://docs.speechify.ai/build/guides/text-to-speech/ssml |
停顿也是内容,别让它悄悄消失。