设计实践

Udio:第二段主歌通常沿用第一段的旋律:如果某一行的音节比第一段多出一倍

第二段主歌通常沿用第一段的旋律。如果某一行的音节比第一段多出一倍,生成的演唱往往会挤成一团。 这篇做 meter.py:用 CMU 发音词典计算每行的音节数,逐行比较同类段落,在粘贴进 Udio 之前先把歌词“对齐”。

BINBHO · 图文教程

第二段主歌通常沿用第一段的旋律。如果某一行的音节比第一段多出一倍,生成的演唱往往会挤成一团。 这篇做 meter.py:用 CMU 发音词典计算每行的音节数,逐行比较同类段落,在粘贴进 Udio 之前先把歌词“对齐”。

先看清成品

  • song.txt 的主歌和副歌音节基本对齐。
  • song_v2.txt 中 Verse 2 的第 2 行是 21 个音节(参考 10),第 3 行是 5 个音节,都被报出。
  • 修复了估算偏差:glovebox 从 3 个音节修正为 2 个,mile's 从 2 个修正为 1 个。pytest 5 项通过。

合规提醒:根据 Udio 帮助中心,自 2025-10-29 与 Universal Music Group 合作起,音频、视频和分轨的下载已经关闭,歌曲只能通过 udio.com 链接分享。本文因此不涉及任何下载、录制或转换 Udio 音频的做法。网上那些“Udio 下载器”和录屏方法违反官方规则,不要使用。提示词里不要写在世艺人的名字,也不要使用他人受版权保护的歌词;Udio 表示会过滤对艺人声音和版权作品的复制。不共享账号。本文不列价格,套餐和额度以官网为准。

运行说明:box 上没有 Udio 账号,没有生成过歌曲。本地脚本和测试都在 box 上真实运行过(Python 3.13;C 篇使用 pronouncing 0.3.0 和 CMU 发音词典)。示例歌词是我自己写的;示例中的 Udio 链接 ID 是虚构的。

做完之后你会得到什么?

成果 说明
meter.py --show 显示每行音节数;有偏差时退出码为 1
lyrics.py 与 A 篇相同
song.txt、song_v2.txt 对齐和不对齐的示例
test_meter.py 5 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上(box 上是 3.13);测试需要 pip install pytest
Udio 网页版 https://www.udio.com,在 Create → Custom 中粘贴歌词
依赖 pip install pronouncing(内含 CMU 发音词典)

关键事实

事实 来源
Custom 模式下在歌词框按 / 可以选择标签;官方列出了 28 个标签,但说明这不是完整列表,也不是硬性指令 Guidance tags
音节数来自 CMU Pronouncing Dictionary,查不到的词用规则估算 pronouncing 库文档

音节对齐只是写词时的参考,Udio 怎样演唱由模型决定。只适用于英文歌词。

文件结构

67-udio-C-src/
├── meter.py
├── lyrics.py
├── song.txt
├── song_v2.txt
└── test_meter.py

第 1 步:音节比较脚本

"""比较同类段落(Verse 1 与 Verse 2、每次副歌)每行的音节数。第二段主歌通常沿用第一段的旋律,
音节数相差太多时,生成的演唱容易挤在一起或拖长。音节数来自 CMU 发音词典(pronouncing 库),
词典里没有的词用元音组规则估算,并标出 '~'。只适用于英文歌词。"""
import argparse, re, sys
import pronouncing
from lyrics import parse, base_tag

def word_syll(w):
    w = w.lower().strip("'")
    ph = pronouncing.phones_for_word(w)
    if ph: return pronouncing.syllable_count(ph[0]), True
    if w.endswith("'s") and pronouncing.phones_for_word(w[:-2]):          # mile's → mile('s 不加音节,-ce/-se 等例外忽略)
        return pronouncing.syllable_count(pronouncing.phones_for_word(w[:-2])[0]), True
    for k in range(3, len(w) - 2):                                          # 复合词:glovebox → glove + box
        p1, p2 = pronouncing.phones_for_word(w[:k]), pronouncing.phones_for_word(w[k:])
        if p1 and p2: return pronouncing.syllable_count(p1[0]) + pronouncing.syllable_count(p2[0]), True
    groups = re.findall(r"[aeiouy]+", w)
    n = len(groups) - (1 if w.endswith("e") and not w.endswith(("le", "ee")) and len(groups) > 1 else 0)
    return max(n, 1), False

def line_syll(line):
    total, exact = 0, True
    for w in re.findall(r"[A-Za-z']+", line):
        n, ok = word_syll(w); total += n; exact &= ok
    return total, exact

def compare(text, tol=2):
    groups = {}
    for tag, lines in parse(text):
        if tag and lines: groups.setdefault(base_tag(tag) or tag, []).append((tag, lines))
    report = []
    for kind, secs in groups.items():
        if len(secs) < 2: continue
        ref_tag, ref = secs[0]
        for tag, lines in secs[1:]:
            for i in range(max(len(ref), len(lines))):
                a = line_syll(ref[i]) if i < len(ref) else None; b = line_syll(lines[i]) if i < len(lines) else None
                if a is None or b is None:
                    report.append((kind, tag, i + 1, a, b, "行数不同")); continue
                if abs(a[0] - b[0]) > tol:
                    report.append((kind, tag, i + 1, a, b, f"相差 {abs(a[0] - b[0])} 个音节"))
    return report

def fmt(x): return "-" if x is None else f"{x[0]}{'' if x[1] else '~'}"

def main(argv=None):
    ap = argparse.ArgumentParser(); ap.add_argument("lyrics"); ap.add_argument("--tol", type=int, default=2); ap.add_argument("--show", action="store_true")
    a = ap.parse_args(argv); text = open(a.lyrics, encoding="utf-8").read()
    if a.show:
        for tag, lines in parse(text):
            for l in lines: print(f"[{tag}] {fmt(line_syll(l)):>4}  {l}")
    rep = compare(text, a.tol)
    for kind, tag, n, x, y, why in rep: print(f"[{tag}] 第 {n} 行:参考 {fmt(x)},本段 {fmt(y)} —— {why}")
    print("音节基本对齐" if not rep else f"{len(rep)} 行需要调整")
    return 1 if rep else 0

if __name__ == "__main__":
    sys.exit(main())

第 2 步:不对齐的示例

[Verse 1]
Windows down on the coastal road
Maps in the glovebox we never unfold
Gas station coffee and a borrowed tune
We'll be at the ocean by the afternoon

[Chorus]
Turn it up, let the summer in
Every mile is a place to begin

[Verse 2]
Sunburned arms and a cassette that skips
Salt in the air and the radio playing every song we used to know by heart
Nobody's waiting
Where the long road goes when the evening glows

[Chorus]
Turn it up, let the summer in
Every mile's a place to begin

第 3 步:测试

import meter as M

def test_dictionary_words():
    assert M.word_syll("summer") == (2, True) and M.word_syll("afternoon") == (3, True)

def test_fallbacks_fixed_in_debugging():
    # 第一版用元音组估算:glovebox=3、mile's=2,都多算了 1
    assert M.word_syll("glovebox") == (2, True) and M.word_syll("mile's") == (1, True)
    assert M.word_syll("zxqvbrt")[1] is False             # 真正查不到的词标记为估算

def test_song_aligned():
    assert M.compare(open("song.txt", encoding="utf-8").read()) == []

def test_song_v2_mismatch():
    rep = M.compare(open("song_v2.txt", encoding="utf-8").read())
    assert [(r[1], r[2]) for r in rep] == [("Verse 2", 2), ("Verse 2", 3)]
    assert rep[0][4][0] - rep[0][3][0] > 2

def test_line_count_difference():
    rep = M.compare("[Verse 1]\na b\nc d\n[Verse 2]\na b\n")
    assert rep[0][5] == "行数不同"

第 4 步:运行

$ python meter.py song.txt --show
[Verse 1]    8  Windows down on the coastal road
[Verse 1]   10  Maps in the glovebox we never unfold
[Verse 1]   10  Gas station coffee and a borrowed tune
...
音节基本对齐
$ python meter.py song_v2.txt
[Verse 2] 第 2 行:参考 10,本段 21 —— 相差 11 个音节
[Verse 2] 第 3 行:参考 10,本段 5 —— 相差 5 个音节
2 行需要调整
$ python -m pytest -q
5 passed

怎么检查结果?

  • 带 ~ 的数字是估算值,需要自己数一下。
  • 默认允许相差 2 个音节(--tol);节奏自由的曲风可以调大。
  • 行数不同时也会报出。

调试示例

估算规则多算了音节。 第一次运行 --show 时,有两行带 ~(表示词典里查不到、用规则估算):

[Verse 1]  11~  Maps in the glovebox we never unfold
[Chorus]   10~  Every mile's a place to begin

自己数一下,两行实际是 10 和 9。原因是:

  • glovebox 是复合词,词典里没有;元音组规则把 glove 中不发音的 e 也算成了一个音节,得到 3(实际是 2);
  • mile's 的 's 使结尾的 e 不再是最后一个字母,“去掉结尾不发音 e”的规则没有生效,得到 2(实际是 1)。

修复:带 's 的词先查去掉 's 后的原词;查不到的词尝试拆成两个词典里都有的词(glove + box)。修复后两行都变成精确值,不再带 ~。

常见问题

现象 原因 处理
出现很多 ~ 用了俚语、人名或自造词 自己数一下,或者换个写法
报“行数不同” 两段主歌的行数不一样 补齐,或确认是有意为之
中文歌词 CMU 词典只收录英文 中文可以按字数比较,本工具不支持
对齐了但唱得仍然不顺 重音和韵脚也会影响 多生成几次,或在 Udio 中重新生成该段

模板规则

  • 同类段落的每一行音节数尽量接近。
  • 带 ~ 的估算值要人工确认。
  • 对齐是参考,不是硬性要求。

项目资源

用途 地址
Guidance tags(官方标签列表) https://help.udio.com/en/articles/11166249-level-up-your-creations-with-guidance-tags
UMG 合作带来的变化(下载已关闭) https://help.udio.com/en/articles/12683565-changes-associated-with-the-universal-music-group-umg-partnership
pronouncing 库文档 https://pronouncing.readthedocs.io/

写词时先把音节对齐,生成出来的演唱更容易贴合旋律。