AI 工具

用 Speechify SSML 构建器生成语音标记:XML 校验与字符限制

SSML 可以控制停顿、语速、音高和读法,但手写容易出错:忘了转义 & 就是 400,停顿加多了会被悄悄丢掉。 这篇做 ssmlbuild.py:把带简单标记的讲稿(# 标题、[停 1.5s]、[[STEM|stem]])转成 SSML,并按官方规则在本地检查:转义、XML 合法性、计费字符数、单个停顿和整篇停顿总长。

BINBHO · 图文教程

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&apos;")

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 "&amp;" in s and "&lt;" in s and "&apos;" in s and "&quot;" 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 &amp; 2 DVDs.
<break strength="strong"/><prosody rate="slow">Children&apos;s corner</prosody><break strength="medium"/>
Story time is on Saturdays. Ask staff about the <sub alias="stem">STEM</sub> club for ages 8 &lt; 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

停顿也是内容,别让它悄悄消失。