长曲子后段 MuScriptor 转出的音符变少——是切片太长还是显存吃紧?

文章导读
长曲子后段 MuScriptor 转出的音符变稀,多数情况下不是模型“听累了”,而是两个可控因素之一先碰到上限:输入的有效序列长度超出模型能稳定处理的窗口,或者推理过程中显存吃紧,触发了分批、降级、换页甚至进程被系统终止。两者听起来都是“越往后越空”,但排查路径完全不同——序列长度受限通常在一个相对固定的时间点开始掉音符,切片后该问题会消失;显存问题则和曲子长度、采样率、批大小、同时跑几个进程一起
📋 目录
  1. A 把长曲按固定时长切片,统计每段输出音符数
  2. B 对比整段输入与切片输入的输出差异
  3. C 观察推理过程中的显存占用变化
  4. D 检查导出的 MIDI 是否在长序列上被截断
  5. E 给出结论边界:切片长度与显存谁先成为限制
A A

长曲子后段 MuScriptor 转出的音符变稀,多数情况下不是模型“听累了”,而是两个可控因素之一先碰到上限:输入的有效序列长度超出模型能稳定处理的窗口,或者推理过程中显存吃紧,触发了分批、降级、换页甚至进程被系统终止。两者听起来都是“越往后越空”,但排查路径完全不同——序列长度受限通常在一个相对固定的时间点开始掉音符,切片后该问题会消失;显存问题则和曲子长度、采样率、批大小、同时跑几个进程一起漂移,同一条曲子跑两次结果都可能不一样。可以先把“变少”变成一条可看的曲线,再结合显存观测和 MIDI 尾部核对,基本能定位到是哪一环丢了音符。

先把长曲按固定时长切片,逐段统计 note_on 数量,画出密度曲线,同时保留一次整段输入的对照跑。曲线在固定时间点下降、切片后恢复,指向有效序列长度;显存峰值贴近卡容量、伴随推理速度骤降或进程退出码 137,指向显存。再用 MIDI 末尾时间戳与音频总时长核对,排除写出环节截断。三处都排除后,再定切片长度上限,结论只在本次参数和机器条件下成立。

把长曲按固定时长切片,统计每段输出音符数

不要按乐句切,按固定时长切,这样每段的可比性最强,曲线也最容易读出趋势。先用 ffmpeg 把原始音频切成等长片段,段间保留少量重叠,避免切点正好落在音符中间造成误判:

ffmpeg -i song.wav -f segment -segment_time 30 -c copy seg_%03d.wav
# 如果容器对 -c copy 不友好,去掉 -c copy 让它重新编码

切片完成后,对每个输出 MIDI 统计 note_on 数量(velocity > 0,避免把 note_off 记进去),并用 mido 读出文件时长,算出音符密度。这个脚本放在输出目录下执行:

import glob, mido
for p in sorted(glob.glob('out_seg_*.mid')):
    mid = mido.MidiFile(p)
    n = sum(1 for t in mid.tracks for m in t if m.type == 'note_on' and m.velocity > 0)
    print(p, 'notes=%d' % n, 'len=%.1fs' % mid.length, 'per10s=%.1f' % (n / max(mid.length, 0.1) * 10))

记录表格建议至少包含这些字段,缺一项后面就很难下判断:段号、起始时间、时长、输入样本率、输出 note_on 数、每 10 秒音符数、单段推理耗时、推理期间 GPU 显存峰值。把每 10 秒音符数按段号画成折线,重点看两件事:曲线在哪个时间点开始明显下降(是持续滑坡,还是某一刀之后断崖),以及下降到零之后是否再也不回升。断崖式下降更偏向切片边界或序列窗口,持续滑坡更偏向显存逐步累积或热量降频。

对比整段输入与切片输入的输出差异

这一步的目的,是把“序列长度”和“输入方式”分开。做法很简单:只改切片,其余参数一律不动——模型权重、采样率、温度、beam 或 top_p、随机种子(如果支持固定)、导出设置全部保持一致,两次跑完分别存到不同目录。整段跑一次,切片跑一次,然后逐段比对同一时间区间内的音符数量。

比对时注意对齐时间轴:切片输出是从 0 开始的局部时间,需要加上该段的起始偏移才是全局时间。可以用下面的方式粗对齐,边界 ±1 秒内的音符先不算,避免切点切断音符造成的假差异:

import mido
def events(path, offset=0.0):
    mid = mido.MidiFile(path)
    t, res = 0.0, []
    for msg in mid:  # 合并轨道、按绝对时间遍历
        t += msg.time
        if msg.type == 'note_on' and msg.velocity > 0:
            res.append(t + offset)
    return res

full = events('out_full.mid')
segs = []
for i, p in enumerate(sorted(glob.glob('out_seg_*.mid'))):
    segs += events(p, offset=i * 30.0)
# 按 10 秒窗口分别计数,逐窗口比较 full 与 segs

判读规则:如果整段输入在中后段掉音符,而切片输入的同一时间区间音符数正常,问题出在有效序列长度或模型对长上下文的处理上;如果切片和整段在对应区间表现一致地稀,那问题不在切片方式,往显存、解码参数或导出环节去找。

观察推理过程中的显存占用变化

推理时开一个终端持续采样显存,别只看结束后的一次快照:

长曲子后段 MuScriptor 转出的音符变少——是切片太长还是显存吃紧?
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu `--format`=csv -l 1
# 或者在另一个窗口
watch -n 1 nvidia-smi

三种情况要区分开。显存耗尽通常伴随明确的 CUDA out of memory 报错,进程还在但推理中断,日志里能看到分配失败的调用栈;内存换页表现为显存并不满、但推理速度明显变慢,同时 vmstat 的 si/so 两列持续非零,说明主机内存在和显存之间来回搬数据;进程被系统终止则不会有 Python 异常栈,退出码常见 137(被 SIGKILL),dmesg 或 journalctl 里能看到 oom-killer 的记录:

vmstat 1 10
dmesg -T | tail -50
journalctl -k | grep -i -E 'oom|killed process'

把采样到的显存峰值对应到段号上,就能看出是“单段就吃满”(切片太长)还是“越跑越高”(有东西在累积没释放,比如缓存没清、多次加载模型、批大小过大)。前者调切片长度,后者先查代码里的显存释放逻辑,不要急着改模型参数。

检查导出的 MIDI 是否在长序列上被截断

写出环节也会丢音符,而且现象和模型掉音符很像,所以必须单独排一次。核对思路是拿 MIDI 的时间长度和输入音频的总时长对齐:

ffprobe -v error -show_entries format=duration -of csv=p=0 song.wav
python -c "import mido;m=mido.MidiFile('out_full.mid');print('midi_len=%.1f' % m.length)"

两者差个一两秒属于正常(尾部静音、最后一个音的释放时间),但如果 MIDI 长度明显短于音频时长,就说明写出被截断了。截断的典型现象有三类:最后一个音符没有对应的 note_off,在部分播放器里会显示成悬挂音;末尾若干小节所有轨道同时归零,而前面密度正常;文件能被解析但 ticks_per_beat 或轨道长度字段和实际事件数对不上。遇到这些情况,先查导出代码里有没有按固定长度或固定事件数写死的上限,再看写入时是否用了非阻塞的流式写出而没有 flush。

给出结论边界:切片长度与显存谁先成为限制

把前面三组证据放在一起,通常能收敛到下面几种判断:

  • 切片变短后同区间音符恢复,显存峰值远低于容量上限:先成为限制的是有效序列长度。调整方向是缩短单段输入、增加段间重叠、必要时把长曲拆成乐章级别分别转写再拼接。
  • 切片变短后音符没改善,显存峰值贴近容量或出现换页:先成为限制的是显存。调整方向是减小批大小、降低同时运行的进程数、缩短单次推理的上下文,先让显存留出余量再看音符密度。
  • 切片变短后音符恢复,但显存依然吃紧:两者都在起作用,先解决显存(否则结果不可重复),再用切片长度去逼近序列上限。
  • 切片和整段都没问题,MIDI 长度却短于音频:问题在导出,不在模型,直接改写出逻辑。

实际选参数时,可以先用 30 秒左右作为起点跑一遍,确认后半段密度没有明显下降后,再按 15 秒一档往上加,直到每 10 秒音符数开始下降,取下降前一档作为上限。这个上限只对本次的模型版本、采样率、机器显存和并行度成立;换设备、换模型或换音频采样率后,都需要重新跑一次切片对照,不要直接把上一轮的数字搬过去用。