ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MusicXML批量处理实战:从140个乐谱文件到旋律相似度检索

MusicXML批量处理实战:从140个乐谱文件到旋律相似度检索

简介:这份资源是面向音乐学习者、作曲者与音乐教育者的MusicXML乐谱源文件合集,共收录140个文件,涵盖94个musicxml、31个xml、14个mxl及1个base64文件,压缩包约284KB。MusicXML作为开放标准的乐谱标记语言,可被MuseScore、Sibelius、Finale等主流软件读取与编辑,方便查看、修改、打印乐谱或生成音轨。从内容预览看,既有莫扎特单簧管五重奏、G大调弦乐四重奏等古典作品片段,也包含鼓组连音、和弦符号、隐形音符等制谱功能测试文件,兼顾经典曲目与格式特性演示。这些文件记录了音符、节奏、力度、乐器指定与分谱等细节,可用于分析作品结构、编排练习或作为创作参考。目前已有665人学习下载,适合需要标准化乐谱素材的读者按需取用。

1. 拿到 140 个 MusicXML 乐谱源文件,先别急着解压

如果你手头正好有一批 MusicXML 格式的乐谱源文件,比如 140 个.musicxml或.xml文件打包在一个.7z压缩包里,第一反应可能是“先解压看看里面有什么”。但真正做过乐谱数据处理的人会告诉你,解压只是最不重要的一步。MusicXML 是一种基于 XML 的乐谱交换格式,它记录的不只是音符,还有谱号、调号、拍号、连音线、力度记号、歌词、和弦标记,甚至排版信息。140 个文件意味着你可能面对的是 140 首不同风格、不同来源、不同完整度的乐谱,它们可能来自不同制谱软件,遵循不同版本的 MusicXML schema,内部结构差异极大。这批文件能解决的问题很具体:批量分析旋律走向、构建乐谱数据集、做自动配器实验、训练符号音乐生成模型,或者单纯把纸质谱数字化后做统一管理。适合谁?做音乐信息检索的研究生、搞自动作曲的算法工程师、需要批量处理乐谱的音乐科技从业者,以及任何想把乐谱当结构化数据来用的人。这一篇不讲空泛概念,只讲怎么把这 140 个文件从压缩包变成能跑通分析流程的干净数据。

2. MusicXML 文件结构拆解:从 score-partwise 到 note 节点

2.1 为什么 MusicXML 比 MIDI 更适合做乐谱分析

MIDI 记录的是演奏事件,音高、力度、时长,但它不关心你用的是高音谱号还是低音谱号,也不记录连音线怎么画、歌词怎么对齐。MusicXML 不一样,它是为“乐谱”本身设计的。一个典型的score-partwise文档里,part下面挂measure,measure里面才是note、backup、forward、direction这些元素。note节点里又有pitch、duration、voice、type、stem、lyric等子节点。这种层级结构让你能精确还原乐谱的视觉和逻辑信息。比如你要做和声分析,可以直接读harmony节点;要做旋律提取,就遍历所有part里的note,按voice分组。MIDI 做不到这些,因为 MIDI 没有“声部”这个概念,也没有调号变化的时间点。所以如果你的目标是乐谱级别的分析,MusicXML 是更合适的起点。常见做法是先用 Python 的music21或partitura库把 MusicXML 解析成内部对象,再转成音符序列或和弦序列。我一般会先用music21快速看一眼文件能不能正常解析,因为有些从 PDF 转出来的 MusicXML 会有非法嵌套或缺失的duration,直接读会抛异常。

2.2 用 Python 批量解析 140 个文件的最小代码框架

假设你已经把.7z解压到了一个目录,里面是 140 个.musicxml文件。下面这段代码用music21批量读取,并输出每个文件的基本信息:调号、拍号、声部数、音符总数。注意music21对文件名和路径比较敏感,最好用绝对路径。

import os from music21 import converter, stream # 替换成你解压后的实际目录 score_dir = "/path/to/your/musicxml_folder" results = [] for fname in os.listdir(score_dir): if not fname.lower().endswith((".musicxml", ".xml")): continue fpath = os.path.join(score_dir, fname) try: score = converter.parse(fpath) # 获取第一个 part 的调号与拍号 key_sig = score.parts[0].getElementsByClass('KeySignature') time_sig = score.parts[0].getElementsByClass('TimeSignature') key_str = key_sig[0].sharps if key_sig else "N/A" time_str = time_sig[0].ratioString if time_sig else "N/A" note_count = len(score.recurse().notes) results.append({ "file": fname, "parts": len(score.parts), "key_sharps": key_str, "time_sig": time_str, "notes": note_count }) except Exception as e: print(f"解析失败: {fname}, 错误: {e}") # 打印前 5 条看看 for r in results[:5]: print(r)

这段代码的逻辑很直接:遍历目录,用converter.parse把每个文件读成stream.Score对象,然后从第一个part里提取调号和拍号,最后统计所有音符数量。参数说明:score.parts[0]取的是第一个声部,如果你的文件是总谱,第一个声部通常是小提琴或主旋律;getElementsByClass是music21的通用查询方法,返回一个列表,所以取[0]之前要判断是否为空。score.recurse().notes会递归遍历所有层级的音符,包括和弦里的音符。如果你只想要单音旋律,可以用score.parts[0].flat.notes。注意music21解析大文件时内存占用会比较高,140 个文件如果每个都很大,建议分批处理,每批 20 个,处理完手动del score并调用gc.collect()。

2.3 处理压缩包时的目录结构与文件命名规范

.7z解压后常见的情况是:所有文件平铺在一个目录里,文件名可能是001.musicxml、002.musicxml,也可能是song_title.musicxml。如果文件名是中文或包含空格,music21在 Windows 上有时会报编码错误。稳妥的做法是先用脚本把文件名统一成 ASCII 字符加序号,同时保留一份映射表。另外,有些 MusicXML 文件会附带一个同名的.mxl压缩格式,.mxl本质是 zip 包,music21也能直接读,但如果你用os.listdir过滤后缀,记得把.mxl也加进去。我一般会先跑一个file命令或者用 Python 的zipfile检查一下文件头,确认没有损坏的压缩包混在里面。140 个文件不算多,但手动检查不现实,写个循环打印每个文件的os.path.getsize和解析状态,能快速定位问题文件。

3. 批量转 MIDI 与 CSV:把乐谱变成可计算数据

3.1 用 music21 批量导出 MIDI 并控制量化精度

把 MusicXML 转成 MIDI 是为了后续用深度学习框架处理,比如用pretty_midi读成 piano-roll。但直接转会有一个坑:MusicXML 里的连音线、装饰音、延音踏板在 MIDI 里表达方式不同,如果不做量化,导出的 MIDI 会有大量极短音符或重叠音符。下面这段代码在转换时做了三件事:先解析,再量化到十六分音符,最后写出 MIDI。

from music21 import converter, stream, tempo def xml_to_midi(xml_path, midi_path, quantize_div=4): score = converter.parse(xml_path) # 量化到十六分音符,quantize_div=4 表示以四分音符为 1,分成 4 份 score.quantize([16], processOffsets=True, processDurations=True, inPlace=True) # 设置一个统一的 tempo,避免 MIDI 速度混乱 mm = tempo.MetronomeMark(number=120) score.insert(0, mm) score.write('midi', fp=midi_path) # 批量转换 import os xml_dir = "/path/to/musicxml_folder" midi_dir = "/path/to/midi_output" os.makedirs(midi_dir, exist_ok=True) for fname in os.listdir(xml_dir): if fname.lower().endswith((".musicxml", ".xml")): xml_path = os.path.join(xml_dir, fname) midi_path = os.path.join(midi_dir, os.path.splitext(fname)[0] + ".mid") try: xml_to_midi(xml_path, midi_path) except Exception as e: print(f"转换失败: {fname}, {e}")

逻辑说明:quantize方法把音符的起始时间和持续时间对齐到最近的十六分音符网格,processOffsets和processDurations分别控制是否调整起始点和时长。参数quantize_div=4对应十六分音符,如果你要更细的精度可以改成8(三十二分音符),但太细会导致 MIDI 文件里音符碎片化。tempo.MetronomeMark插入一个统一的 120 BPM,是因为很多 MusicXML 文件没有显式速度标记,不插入的话music21会默认用 120,但有些文件会继承上一个文件的设置,导致批量转换时速度不一致。注意score.write('midi')在 Windows 上路径不要有中文,否则可能写不出来。

3.2 提取音符序列到 CSV 的字段设计与代码

如果你要做统计分析,比如音高分布、音程跳进、节奏密度,把音符导出成 CSV 是最灵活的。字段设计我一般用:file_name, part_id, measure_num, offset, pitch_name, midi_pitch, duration, voice, lyric。下面是对应的提取代码。

import csv from music21 import converter def extract_notes_to_csv(xml_path, csv_writer, file_name): score = converter.parse(xml_path) for part in score.parts: part_id = part.id for note in part.recurse().notes: # 获取音符所在的小节号 measure = note.getContext('Measure') measure_num = measure.number if measure else -1 # 音名和 MIDI 音高 if note.isNote: pitch_name = note.nameWithOctave midi_pitch = note.pitch.mid elif note.isChord: pitch_name = ".".join(p.nameWithOctave for p in note.pitches) midi_pitch = ".".join(str(p.mid) for p in note.pitches) else: continue # 歌词 lyric = note.lyric if note.lyric else "" csv_writer.writerow([ file_name, part_id, measure_num, float(note.offset), pitch_name, midi_pitch, float(note.duration.quarterLength), note.voice, lyric ]) # 主流程 xml_dir = "/path/to/musicxml_folder" csv_path = "/path/to/output_notes.csv" with open(csv_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["file", "part", "measure", "offset", "pitch", "midi", "duration", "voice", "lyric"]) for fname in os.listdir(xml_dir): if fname.lower().endswith((".musicxml", ".xml")): try: extract_notes_to_csv(os.path.join(xml_dir, fname), writer, fname) except Exception as e: print(f"提取失败: {fname}, {e}")

这段代码的关键点:note.getContext('Measure')能拿到音符所属的小节对象,但前提是 MusicXML 里正确标记了measure元素,有些非标准文件会缺失。note.offset是相对于所在part的偏移量,单位是四分音符。note.duration.quarterLength也是以四分音符为 1。note.voice可能是None,在 CSV 里会写成空字符串。如果你要分析复调音乐,voice字段很重要,因为同一时间可能有多个音符属于不同声部。注意note.isChord的处理,和弦会输出多个音高用点号连接,后续分析时可能需要拆开。

3.3 用 pandas 做初步质量检查:缺失值与异常时长

导出 CSV 之后,别急着跑模型。先用 pandas 做一轮质量检查,看看有没有缺失的拍号、异常短的音符、或者某个文件的音符数量明显偏少。下面这段代码输出每个文件的音符数、平均时长、以及是否存在时长为 0 的音符。

import pandas as pd df = pd.read_csv("/path/to/output_notes.csv") # 按文件分组统计 stats = df.groupby("file").agg( note_count=("pitch", "count"), avg_duration=("duration", "mean"), min_duration=("duration", "min"), max_duration=("duration", "max") ).reset_index() # 找出音符数少于 10 的文件,可能是解析失败或空谱 suspicious = stats[stats["note_count"] < 10] print("可疑文件:") print(suspicious) # 检查时长为 0 的音符 zero_dur = df[df["duration"] == 0] print(f"时长为 0 的音符数量: {len(zero_dur)}") print(zero_dur.head())

参数说明:groupby("file")按文件名聚合,agg里可以放多个统计函数。note_count用pitch列计数,因为每个音符至少有一个音高。min_duration为 0 说明有音符时长为零,这在 MusicXML 里通常是装饰音或 grace note,music21会把它们标记为duration=0。如果你不想要装饰音,可以在提取时加一个判断if note.duration.quarterLength == 0: continue。suspicious里如果出现你认识的曲名,但音符数很少,那多半是文件本身有问题,比如只写了前几个小节,或者解析时因为 XML 命名空间问题漏掉了内容。

4. 避坑与排查:140 个文件里最常见的 5 个翻车点

4.1 现象:解析时报“XML syntax error”,原因:文件头有 BOM 或非法字符

有些 MusicXML 文件是从文本编辑器里保存的,开头带了 UTF-8 BOM(字节顺序标记),music21底层的 lxml 解析器会直接报错。解决方法是先用 Python 读一遍文件,去掉 BOM 再写回临时文件,或者用codecs模块以utf-8-sig编码打开。更稳妥的做法是在批量解析前统一做一次清洗:读入原始字节,如果前三个字节是\xef\xbb\xbf就跳过它们,然后重新写入。这个坑在 140 个文件里可能只出现两三个,但一旦出现就会中断整个循环,所以异常捕获一定要写。

4.2 现象:转 MIDI 后音符全部堆在第一个小节,原因:缺少 backup 或 forward 导致时间轴错乱

MusicXML 里多声部是通过backup和forward元素来调整时间轴的。如果制谱软件导出时漏掉了backup,music21解析时会把所有声部的音符都按顺序累加,结果就是所有音符挤在第一小节。排查方法是打开原始 XML,搜索<backup>标签,看看每个measure里是否有成对的backup和forward。如果没有,可以在music21里手动调用score.makeMeasures(inPlace=True)或者用score.parts[0].flat强制展平。但更好的做法是换一个解析库,比如partitura,它对时间轴的处理更鲁棒。

4.3 现象:CSV 里 duration 全是 0,原因:音符是 grace note 或解析时未展开连音线

duration为 0 有两种可能:一是装饰音,二是连音线(tie)的第二个音符。music21对连音线的处理是保留两个音符对象,但第二个音符的duration可能被设为 0,实际时长要合并到第一个音符上。如果你直接统计时长,会得到错误结果。解决方法是遍历时检查note.tie,如果note.tie.type == 'stop',就跳过这个音符,把它的时长加到前一个start音符上。或者用score.flat.notes并调用stripTies()方法,music21会自动合并连音线。

4.4 现象:中文歌词变成乱码,原因:XML 声明编码与实际编码不一致

MusicXML 文件头通常有<?xml version="1.0" encoding="UTF-8"?>,但有些文件实际保存成了 GBK 编码,导致歌词乱码。排查时用chardet检测文件编码,或者直接用二进制模式读入,看 XML 声明里的 encoding 是否和实际字节匹配。解决方法是统一转成 UTF-8:用open(fpath, 'rb')读入,decode('gbk', errors='ignore')再encode('utf-8')写回。注意errors='ignore'会丢弃无法解码的字符,如果歌词很重要,建议用errors='replace'并人工检查替换后的问号。

4.5 现象:批量处理到第 80 个文件时内存溢出,原因:music21 缓存未释放

music21在解析每个文件时会创建大量对象,并且默认会缓存一些解析结果。140 个文件如果每个都很大,内存会持续增长。解决方法是在循环里显式删除score对象,并调用gc.collect()。另外可以设置music21.environment.set('autoDownload', 'allow')为'deny',避免它去下载不必要的外部资源。如果还是不够,就分批处理,每 20 个文件重启一次 Python 进程,用 shell 脚本包一层循环。

5. 进阶:用这批乐谱做旋律相似度检索的一个具体技巧

当你把 140 个 MusicXML 都转成了音符序列,一个很自然的进阶需求是:找出其中旋律相似的曲子。直接比较原始音高序列会受调性影响,比如 C 大调和 G 大调的同一段旋律,音高数字完全不同。我一般会先做调性归一化,再把音符转成音程序列,最后用编辑距离或 n-gram 余弦相似度来算。下面这个技巧的核心是:只取每个文件的主旋律声部,转成相对于第一个音的音程,然后截取前 32 个音作为指纹。

from music21 import converter, interval def melody_fingerprint(xml_path, max_notes=32): score = converter.parse(xml_path) # 取第一个 part 的最高声部作为主旋律 part = score.parts[0] notes = part.flat.notes # 过滤掉和弦,只保留单音 melody = [n for n in notes if n.isNote] if len(melody) < 2: return None # 转成音程序列 intervals = [] for i in range(1, min(len(melody), max_notes)): try: iv = interval.Interval(melody[i-1].pitch, melody[i].pitch) intervals.append(iv.semitones) except: intervals.append(0) return intervals # 计算两个指纹的余弦相似度 import numpy as np def cosine_sim(a, b): if len(a) != len(b): # 补齐到相同长度 min_len = min(len(a), len(b)) a, b = a[:min_len], b[:min_len] a, b = np.array(a), np.array(b) if np.linalg.norm(a) == 0 or np.linalg.norm(b) == 0: return 0.0 return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # 示例:比较前两个文件 import os xml_dir = "/path/to/musicxml_folder" files = [f for f in os.listdir(xml_dir) if f.lower().endswith((".musicxml", ".xml"))] fp1 = melody_fingerprint(os.path.join(xml_dir, files[0])) fp2 = melody_fingerprint(os.path.join(xml_dir, files[1])) if fp1 and fp2: print(f"相似度: {cosine_sim(fp1, fp2):.3f}")

这段代码的逻辑:part.flat.notes把声部展平,n.isNote过滤掉和弦和休止符。interval.Interval计算两个音之间的半音数,正数表示上行,负数表示下行。max_notes=32限制指纹长度,避免长曲子计算量过大。cosine_sim在长度不一致时截断到较短的长度,这在实际比较中够用,因为旋律的开头往往最具辨识度。注意interval.Interval对同音返回 0,对八度返回 12,这些都在后续相似度计算中保留。如果你要更鲁棒,可以把音程序列转成相对音高轮廓(比如只保留上行/下行/保持三种状态),再用 Jaccard 相似度。这个技巧我用了很久,最大的教训是:不要直接比较绝对音高,哪怕两个文件都是 C 大调,移调后的旋律也会被误判为不相似。另外,music21的flat属性会改变音符的 offset,如果你需要保留节奏信息,就不要用flat,而是用recurse().notes并手动按 offset 排序。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进