转谱卡了一周,我发现方向从一开始就错了
目标很明确:把一首人声歌转成 MusicXML,喂进 CeVIO 让人声库唱出来。需要的东西就两样——音高和时长。
我的第一版方案很直接:用 Basic Pitch 把音频转成 MIDI,然后修修补补写成 MusicXML。这是个现成的音符转录模型,看着就该干这个。
转出来的东西没法看
跑完第一遍,数字就不对:
- 286 个音符,其中 225 个是「あ」占位
- 348 个是 16 分、32 分的碎音
「あ」是默认兜底歌词——它不知道这个音符对应哪个字,就填个假名占位。225 个占位意味着大部分音符它都不知道在唱什么。
碎音更麻烦:一个"啊"字拖长音,它切成了七八个音符。
我在给一个错的东西打补丁
接下来的几天我都在"修"这些输出:调参数、写后处理、合并碎音、重新分配歌词。每次改完看着好一点,整体还是乱的——听下来只有一两句能听。
到用户说"歌词加不加没啥作用,还是大范围混乱不堪"的时候,我才停下来重新想这件事。
问题不在后处理,在于音符切分本身就是错的。
Basic Pitch 这类模型是多音符转录,设计目标是给乐器用的:要把音乐里同时响的多个音都识别出来。为了做到这个,它必须去猜 onset(什么时候起音)和 offset(什么时候结束)——因为乐器的音符是断开的,它得找出每个音的边界。
但人声是连奏的。 一个字拖长了,中间没有任何"断开"的信号,模型只能按它学到的习惯切,于是切出一堆碎音。这个错误发生在转录这一层,后面再怎么补都补不回来。
有歌词,就不用猜
转折点是想通一件事:我手里有歌词。
如果有 LRC 或者歌词文本,那音节数是确定的——十个音节就是十个音符,不用模型去猜占多少、切几刀。
新的流程变成这样:
分离人声
→ librosa.pyin 提 f0 曲线
→ LRC 行时间 / 发声段检测 定出每句的时间窗
→ 句内按音节均分时间
→ 每音节取窗内 f0 中位数做音高
关键在第 1 步和第 4 步。
音高跟踪(pitch tracking)比音符分割稳得多,这是成熟技术,输出一条连续的 f0 曲线,不需要判断"音符边界"。
取中位数而不是均值,是因为人声有颤音——f0 会在目标音高上下抖动,取中位数天然免疫这种抖动。
这样出来:音符数 = 音节数,每个音符自带真歌词,一个都不用猜。
两个必踩的坑
新流程跑通之前踩了两个,都属于"不看不知道"的类型。
第一个:整理函数会把歌词丢了。 我在做量化、八度修正、音域检查的时候,这些函数都是用 pretty_midi.Note(...) 新建对象,只搬运了 velocity / pitch / start / end 四个字段——.lyric 没了。结果整首曲子写出来全是 fallback「あ」。
第二个:这条路径绝不能做同音高合并。 有个 merge_repeats 的逻辑会把相邻同音高的音符合并(乐谱上这是常见处理)。但在我这条链路上,相邻同音高意味着不同音节——"か"和"わ"恰好音高一样,合并了就丢字。实测有 16 个音符被合成 1 个。
改成 merge_repeats=False,问题消失。
效果
同一首歌,两条路径对比:
| Basic Pitch | 歌词驱动 | |
|---|---|---|
| 音符数 | 286(碎音成堆) | 84 = 音节数 |
| 「あ」占位 | 225 | 0 |
| 歌词 | 大量占位 | 10 句完整对上 |
输出长这样:ヨ ア ケ ヲ ツ ト リ ガ シ ズ カ ニ ハ バ タ ク …——一个音符一个音节,全是真的。
那几天补丁打下来,不如想清楚"音符数从哪来"这一件事。