AI 工具

用 Speechify Speech Marks 生成 SRT 与 WebVTT 字幕

Speechify 的响应自带 speechmarks:每个词在音频里的开始和结束时间。 这篇做 markstosubs.py:把 speechmarks 转成 SRT 和 WebVTT 字幕,按句子和长度分行,避免一行以 “a / the / at” 这种虚词结尾,并把很短的空隙补上,免得字幕闪烁。

BINBHO · 图文教程

Speechify 的响应自带 speech_marks:每个词在音频里的开始和结束时间。 这篇做 marks_to_subs.py:把 speech_marks 转成 SRT 和 WebVTT 字幕,按句子和长度分行,避免一行以 “a / the / at” 这种虚词结尾,并把很短的空隙补上,免得字幕闪烁。

先看清成品

  • 示例响应(我按官方结构写的)转成 4 条字幕,第一条从 180ms 开始(保留开头静音)。
  • 发现真实问题:第一版断行出现 “…books at a / time, and …”,以及一条 0.4 秒的孤字字幕 “mornings.”。
  • 修复后优先在逗号后断行,不以虚词结尾,孤字并回上一条。pytest 4 项通过。

合规提醒:只朗读你有权使用的文字;把别人的书或文章转成音频再传播可能侵犯版权。不要用 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 生成的正弦波。没有真正合成过语音。 本篇的 speech_marks 是我按官方文档的结构编写的(时间是编的),故意包含开头静音和词间空隙。

做完之后你会得到什么?

成果 说明
marks_to_subs.py speech_marks → SRT / VTT
response.json 示例响应(我写的)
subs.srt / subs.vtt 结果
test_subs.py 4 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,只用标准库;测试需要 pytest

关键事实

事实 来源
speech_marks 顶层是整段:start_time / end_time(毫秒)、start / end(字符位置)、value;chunks 是逐词 官方 Speech marks 文档
第一个词不一定从 0ms 开始;词与词之间的时间和字符位置都可能有空隙 同上
按字符位置找词时用 start >= index,不要判断是否落在 [start, end] 内 同上
字符位置按去掉 SSML 标签、解码实体后的文本计算 同上

文件结构

78-speechify-C-src/
├── marks_to_subs.py
├── response.json
└── test_subs.py

第 1 步:脚本

"""把 Speechify 返回的 speech_marks(词级时间)转成 SRT / WebVTT 字幕。
结构见官方 Speech marks 文档:顶层是整段(start_time/end_time 毫秒、start/end 字符位置、value),chunks 是逐词。
官方提醒:第一个词不一定从 0ms 开始;词与词之间的时间和字符位置都可能有空隙。"""
import argparse, json, sys
from pathlib import Path

def words(marks):
    return [c for c in marks.get("chunks", []) if c.get("value", "").strip()]

WEAK = {"a", "an", "the", "at", "to", "of", "on", "in", "and", "or", "for", "with", "is"}

def _cut(cur):
    """超长时在哪里断:优先最后一个逗号之后;否则别让一行以 a/the/at 这类虚词结尾。"""
    for i in range(len(cur) - 1, 0, -1):
        if cur[i - 1]["value"].endswith((",", ",", ";", ";")): return i
    i = len(cur)
    while i > 1 and cur[i - 1]["value"].lower() in WEAK: i -= 1
    return i

def cues(marks, max_chars=42, max_ms=3500, min_gap_ms=200):
    out, cur = [], []
    def emit(ws):
        out.append({"start": ws[0]["start_time"], "end": ws[-1]["end_time"], "text": " ".join(w["value"] for w in ws)})
    for w in words(marks):
        cur.append(w)
        text = " ".join(x["value"] for x in cur)
        if len(cur) > 1 and (len(text) > max_chars or w["end_time"] - cur[0]["start_time"] > max_ms):
            k = _cut(cur[:-1]); emit(cur[:k]); cur = cur[k:]
        if w["value"].rstrip()[-1:] in ".!?。!?":  # 句末:结束本条字幕
            if len(cur) == 1 and out and out[-1]["text"][-1:] not in ".!?。!?" \
                    and len(out[-1]["text"]) + 1 + len(w["value"]) <= max_chars + 10:  # 孤字并回上一条
                out[-1]["text"] += " " + w["value"]; out[-1]["end"] = w["end_time"]
            else:
                emit(cur)
            cur = []
    if cur: emit(cur)
    for a, b in zip(out, out[1:]):  # 两条之间空隙很短时,把上一条延长到下一条开始,避免闪烁
        if 0 < b["start"] - a["end"] < min_gap_ms: a["end"] = b["start"]
    return out

def ts(ms, sep):
    ms = int(round(ms)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
    return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"

def to_srt(cs): return "\n".join(f"{i}\n{ts(c['start'], ',')} --> {ts(c['end'], ',')}\n{c['text']}\n" for i, c in enumerate(cs, 1))
def to_vtt(cs): return "WEBVTT\n\n" + "\n".join(f"{ts(c['start'], '.')} --> {ts(c['end'], '.')}\n{c['text']}\n" for c in cs)

def word_at(marks, index):
    """官方建议:找字符位置对应的词时用 start >= index,而不是判断 index 是否落在 [start, end] 内。"""
    for w in words(marks):
        if w["start"] >= index: return w
    return None

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("response", help="保存下来的 /v1/audio/speech 响应 JSON")
    ap.add_argument("--max-chars", type=int, default=42); ap.add_argument("-o", default="subs")
    a = ap.parse_args(argv)
    marks = json.loads(Path(a.response).read_text(encoding="utf-8"))["speech_marks"]
    cs = cues(marks, a.max_chars)
    Path(a.o + ".srt").write_text(to_srt(cs), encoding="utf-8"); Path(a.o + ".vtt").write_text(to_vtt(cs), encoding="utf-8")
    print(to_srt(cs)); print(f"{len(cs)} 条字幕 → {a.o}.srt, {a.o}.vtt")

if __name__ == "__main__":
    main()

第 2 步:测试

import json
import marks_to_subs as m

def mk(text, gap=40, t0=180):
    ch, t, pos = [], t0, 0
    for w in text.split(" "):
        st = text.index(w, pos); pos = st + len(w)
        ch.append({"start_time": t, "end_time": t + 300, "start": st, "end": pos, "value": w}); t += 300 + gap
    return {"start_time": 0, "end_time": t, "start": 0, "end": len(text), "value": text, "chunks": ch}

def test_first_cue_keeps_initial_silence():
    assert m.cues(mk("Hello there."))[0]["start"] == 180

def test_no_cue_ends_with_weak_word_and_limits():
    cs = m.cues(mk("Visitors can borrow up to five books at a time and children's story hour is on Saturday mornings in the hall."))
    assert all(c["text"].split()[-1].lower() not in m.WEAK for c in cs)
    assert all(len(c["text"]) <= 52 for c in cs)

def test_gap_closed_and_timestamps():
    cs = m.cues(mk("One. Two.", gap=100))
    assert cs[0]["end"] == cs[1]["start"]
    assert m.ts(3723004, ",") == "01:02:03,004" and m.to_vtt(cs).startswith("WEBVTT")

def test_word_at_uses_start_ge_index():
    marks = mk("ab cd")
    assert m.word_at(marks, 2)["value"] == "cd"   # 索引 2 是空格(空隙),返回下一个词

第 3 步:运行

$ python marks_to_subs.py response.json
1
00:00:00,180 --> 00:00:01,560
The library opens at nine.

2
00:00:01,940 --> 00:00:03,900
Visitors can borrow up to five books

3
00:00:03,900 --> 00:00:04,570
at a time,

4
00:00:04,570 --> 00:00:07,075
and children's story hour is on Saturday mornings.

4 条字幕 → subs.srt, subs.vtt
$ head -5 subs.vtt
WEBVTT

00:00:00.180 --> 00:00:01.560
The library opens at nine.
$ python -m pytest -q
4 passed

怎么检查结果?

  • SRT 时间用逗号分隔毫秒,VTT 用点,并以 WEBVTT 开头。
  • 用播放器同时加载音频和字幕,看高亮是否对齐。
  • 输入是 SSML 时,字符位置对应去掉标签后的文本,不能直接用来定位原始 SSML。

调试示例

断行难看,还有孤字。 第一版只按“超过 42 字符或 3.5 秒就换行,遇到句末就结束”:

2
00:00:01,940 --> 00:00:04,225
Visitors can borrow up to five books at a

3
00:00:04,265 --> 00:00:06,220
time, and children's story hour is on

4
00:00:06,260 --> 00:00:07,075
Saturday mornings.

“at a / time,” 被拆开,“is on / Saturday” 也被拆开。第一次修复(逗号后断行、不以虚词结尾)后,又出现一条只有 0.4 秒的 “mornings.”。最终修复:

  1. 超长时优先在最后一个逗号后断;没有逗号就把行尾的虚词(a、the、at、on……)挪到下一行。
  2. 一句话最后只剩一个词、且并回上一条后不超过 52 个字符时,并回上一条。
  3. 两条字幕间隔小于 200ms 时,把上一条延长到下一条开始,避免闪烁。

常见问题

现象 原因 处理
第一条字幕太早 忽略了开头静音 用第一个词的 start_time
高亮错位 用 [start, end] 判断位置 按官方建议用 start >= index
中文不断行 按空格分词 中文需按字符数断行(本脚本面向英文)

模板规则

  • 字幕时间用词级 speech_marks。
  • 断行避开虚词,优先逗号后。
  • 补上短空隙,避免闪烁。
  • 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
Speech marks 文档(官方) https://docs.speechify.ai/build/guides/text-to-speech/speech-marks
Speechify 官方 GitHub https://github.com/Speechify-AI

音频和字幕对得上,听的人才跟得上。