📌 隶属于架构蓝图: 企业级 AI Agent 与本地 GPU 算力自动化 → L3: 本地 GPU 算力底座与语音/视觉推理

给没有时间戳的歌曲自动生成字幕

· 29 分钟 · 实战教程 subtitleslrcsrtwhisperforced-alignmentai-musicpython

我试过的每一个 AI 音乐工具,交出来的东西形状都一样:一个音频文件。没有一个会告诉你每一句是什么时候唱的。只想听的话没问题,但只要你想把歌词放到屏幕上 —— 卡拉 OK 的那一行、视频字幕、播放器下面的滚动框 —— 就完全不够用。

时间轴才是全部难点。音频反而是简单的部分。

这是我最后跑通的一套流程,用在本地生成的 18 首英文歌上,外加它在浏览器里崩掉的两个地方。下面全部可复现:它唯一需要你提供的东西,是你已经写好的歌词。

你真正需要的两样东西

两种字幕格式,共用同一份数据结构:

格式用在哪长什么样
LRC音乐播放器、卡拉 OK 块、<audio> 歌词层[mm:ss.xx]歌词行
SRTVLC、YouTube、Premiere、ffmpeg 烧字序号 / hh:mm:ss,mmm --> hh:mm:ss,mmm / 文本

两者本质都是「行 → 起始时间」(SRT 多一个结束时间)。所以真正的任务是: 给定一串歌词行和一个音频文件,找出每一行从哪儿开始。

为什么「直接转写」行不通

最直觉的做法:跑一次语音识别,把它的分段结果写出去。它会因为两个原因失败。

1. 唱腔的识别率远低于说话。 模型要同时对付乐器、混响和拉长的元音。我这一批里,中文唱词的词错率大约 25–35%,电子混音密集的更差。

2. 它会整段丢掉。 有一首 140 BPM 的燃曲,整段第一段主歌压根没出现在转写结果里 —— 人声被埋住了,模型直接跳过去。另外两首丢了尾奏。

有洞的转写,变不出没洞的字幕。但换个框法就全通了:

你不是在转写,你是在对齐。你早就知道确切的字 —— 那就是你的歌词。音频只是用来提供锚点。

让它成立的思路反转

让识别器给出词级时间戳,然后拿它的输出跟你已知的歌词做最长公共子序列 diff。你根本不在乎它有没有听对 —— 你在乎的是它听出来的那些词顺序对不对,因为每一个匹配都是一个时间锚点。

import difflib

ref = [u for line in lines for u in units(line)]      # 你的歌词
hyp = [w[0] for w in asr_words]                        # 它听到的
sm = difflib.SequenceMatcher(a=ref, b=hyp, autojunk=False)

for tag, i1, i2, j1, j2 in sm.get_opcodes():
    if tag != "equal":
        continue            # 不匹配的地方只是空洞,不是错误
    for k in range(i2 - i1):
        matched[ref_index[i1 + k]] = asr_words[j1 + k][1:]   # (起, 止)

然后每一行歌词取它第一个匹配单位的时间戳。这种做法的稳健性是转写给不了的:60–70% 的词准确率就完全够用,因为每一个位置正确的词都钉住了它所属的那一行。

这批实测的锚定覆盖率 —— 也就是「拿到了至少一个真实锚点」的行占比:

语言单位平均锚定覆盖率全行锚定的歌
英文词0.969/18 首
中文字0.7011/11 首

英文对齐更好,原因很无聊:词之间用空格隔开,所以每个 ASR token 能干净地对应一个歌词 token。中文则必须把每个字平摊到 token 的时间跨度上:

span = w.end - w.start
for i, ch in enumerate(hanzi_in_word):
    chars.append((ch, w.start + span * i / n, w.start + span * (i + 1) / n))

修正器:三条规则,挡住荒唐的时间

锚点能带你走完大半程。但如果放着不管,锚点之间的空隙会产出物理上不可能的时间 —— 其中两个是真的咬到我了:

故障 A —— 一行落在 0.0 秒。 有一首歌的第一句歌词正好落在零点,前奏都还没结束,因为锚点搜索在开头的喊麦里匹配到了一个零散的字。

故障 B —— 四行挤在 0.6 秒里。 有一首歌的尾奏被识别器整段漏掉,剩下的行被压到相隔 0.18 秒。12 个字不可能在 0.18 秒里唱完。

故障 C —— 六行被死死压在硬下限上。 有一首的整个第一段主歌完全没有可用锚点,于是第 3 条规则把连续六行扫到了最小间隔:每行正好 1.20 秒,大约相当于每秒十个词。输出看起来依然「合理」—— 单调递增、在音频时长范围内 —— 直到我去听那一首,整段主歌六秒就唱完了。只对这一个文件重跑一次对齐,第一行就落到了 10.8 秒而不是 28.2 秒:锚点不是缺失,而是被匹配到了歌里错误的位置。

所以对齐之后,跑三条规则:

  1. 估计这首歌自己的速率 —— 用相邻两行都已锚定的样本(单位数 ÷ 秒数,取中位数)。每行的期望时长 = 单位数 ÷ 速率,下限约 1.8 秒。同一批里英文是 0.96 到 2.33 词/秒,中文是 2.3 到 4.1 字/秒,不能用全局常数。
  2. 按比例填充没锚定的段,而不是固定行距。一段夹在两个锚点之间,就按期望时长分摊;空间不够就等比压缩。
  3. 剔除不可能的锚点 + 强制最小行距。 起始时间早于「5 秒 + 它前面所有内容可能占用的时间」的锚点一定是假匹配 —— 降级重算。然后正向扫一遍,保证任意两行起始间隔不小于 1.2 秒;若尾部被推出音频,再从末尾倒推收回。

最后那趟扫描,才是把「看起来合理」变成「不可能闪烁」的关键。

同时输出两种格式

同一份时间数组,两个写出函数:

def lrc(ts, lines):                      # [mm:ss.xx]
    return "\n".join(f"[{m:02d}:{s:05.2f}]{l}" for (m, s), l in zip(ts, lines))

def srt(spans, lines):                   # hh:mm:ss,mmm
    out = []
    for i, (line, (a, b)) in enumerate(zip(lines, spans), 1):
        out.append(f"{i}\n{stamp(a)} --> {stamp(b)}\n{line}\n")
    return "\n".join(out)

LRC 只要起点;SRT 要起点和终点,所以用下一行的起点当作当前行的结束(这正是最小行距规则重要的原因 —— 没有它,SRT 块会变成零时长,有些播放器会整块跳过)。

验证:我错了两次的地方

字幕早在显示之前就是对的。两个 bug 都在那 20 行播放器脚本里,而且都是读代码看不出来的。

Bug 1 —— 一开播就在末尾。 找当前行我写的是 lines.findIndex(l => l.t > now) - 1。第一句之前 findIndex 返回 0,于是 i 变成 -1,而那句 if (i < 0) i = lines.length - 1 把「还没开始」翻译成了「最后一行」。播放器一打开就停在尾奏。

Bug 2 —— 然后第一个和弦就滚到底。 修完 bug 1,我用 p.offsetTop 来做居中。offsetTop 是相对最近的定位祖先量的 —— 而歌词框没有 position: relative,所以它返回的是这一行到页面顶部的距离,上千像素。scrollTop 被夹到 maxScroll:直接到底,字全看不见。

正确写法根本不关心定位上下文:

const br = box.getBoundingClientRect(), pr = p.getBoundingClientRect();
box.scrollTop += (pr.top - br.top) - (box.clientHeight - pr.height) / 2;

关于「怎么测」的教训比修复本身更重要。 我在 Node 里单测过索引逻辑 —— 通过了。我看过截图 —— 我把高亮的那一行看错了。两者都看不见布局 bug。真正有效的是在 headless Chrome 里打数值探针:stub 掉 currentTime,派发 timeupdate,然后把真实 DOM 读回来。

chrome --headless=new --dump-dom --virtual-time-budget=8000 \
       --window-size=760,520 "file:///probe.html"
Object.defineProperty(a, 'currentTime', { get: () => 100, configurable: true });
a.dispatchEvent(new Event('timeupdate'));
// 然后输出:当前行索引、它在框内的偏移、scrollTop、maxScroll

通过条件是数字,不是观点:当前行在框内的偏移应当约为框高的一半(我得到的 274 px 框里是 124 px);t = 0 时 scrollTop = 0 且没有任何行高亮;只有最后几句时 scrollTop = maxScroll。

结果

18 首歌,没有一首自带时间戳。现在每一首都有逐行的 LRC 和 SRT —— 只靠歌词加音频生成。上面覆盖率表里的中文那一行,来自另一批单独跑的 11 首中文验证集,不是这一页的播放器。

歌曲18
字幕行数342
平均锚定覆盖率0.96
全行锚定的歌9 / 18(每行都有时间)
处理耗时每首 63–90 秒,纯 CPU 跑 faster-whisper

跑在 CPU 上是刻意的,不是我绕不过去的限制:这 18 首全部在 Core i9-13900K 上 (跑这套流程的那台 Windows 虚拟机)完成对齐,因为 GPU 要留给生成这些歌曲的 生产管线,而 faster-whisper int8 每首 63–90 秒已经足够便宜 —— 把对齐任务排在 一个扩散生成任务后面等,省下来的那点 CPU 时间根本不值。

下面每个播放器都会滚动歌词、把当前句保持在中间,旁边还有 .srt 下载 —— 想烧字进视频或在 VLC 里打开都行。

Tiny Borrowed Shoes album cover
Tiny Borrowed Shoes · Celtic folk · 21 lines · 139 s.srt
Mist Over Skellig album cover
Mist Over Skellig · Celtic folk · 18 lines · 193 s.srt
Willow by the Silver Stream album cover
Willow by the Silver Stream · Celtic folk · 19 lines · 197 s.srt
Clockwork Starlight album cover
Clockwork Starlight · Japanese healing · 21 lines · 160 s.srt
Waltz of the Dandelion album cover
Waltz of the Dandelion · Japanese healing · 18 lines · 179 s.srt
Whale in the Milky Way album cover
Whale in the Milky Way · Japanese healing · 20 lines · 164 s.srt
Puddle Jumping Parade album cover
Puddle Jumping Parade · Japanese cute · 21 lines · 105 s.srt
Strawberry Cloud Bakery album cover
Strawberry Cloud Bakery · Japanese cute · 19 lines · 100 s.srt
Captain Marshmallow album cover
Captain Marshmallow · Japanese cute · 22 lines · 95 s.srt
Lanterns on the Lotus Pond album cover
Lanterns on the Lotus Pond · Guofeng fusion · 19 lines · 131 s.srt
Wings of the Kunpeng album cover
Wings of the Kunpeng · Guofeng fusion · 19 lines · 213 s.srt
The Puppeteer's Thread album cover
The Puppeteer's Thread · Guofeng fusion · 19 lines · 221 s.srt
Paper Airplane in September album cover
Paper Airplane in September · Acoustic folk · 18 lines · 128 s.srt
Porridge and Evening Smoke album cover
Porridge and Evening Smoke · Acoustic folk · 17 lines · 144 s.srt
Green Train to the Sea album cover
Green Train to the Sea · Acoustic folk · 17 lines · 108 s.srt
Sanctuary of the Dawn album cover
Sanctuary of the Dawn · Choral · 19 lines · 182 s.srt
Look Upon Your Path album cover
Look Upon Your Path · Choral · 17 lines · 122 s.srt
Bells of the Winter Star album cover
Bells of the Winter Star · Choral · 18 lines · 177 s.srt
💼 合作与雇佣

有高价值项目开发需求,或正在寻找硬核全栈工程师?

从前端交互到边缘网关,从业务系统到自托管基础设施。欢迎与我探讨商业系统外包或全职/远程工程岗位。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts