MATLAB自动扒谱:从音频到钢琴卷帘图与MIDI的完整实现
2026/9/14 14:43:51 网站建设 项目流程

简介:一份使用MATLAB编写的音频转钢琴乐谱工具,专为音乐制作人、作曲编曲者、音乐教育者以及对音频算法与信号处理感兴趣的开发者设计,能够将WAV音频文件自动解析为钢琴五线谱,省去人工听音记谱的繁琐流程。整个资源包共45个文件,大小约9.37MB,包含18个wav测试音频、17个m源码文件、8个fig图形界面文件、1个md说明文档及1个prj工程文件;其中m文件覆盖音频读取、频谱分析、音高检测、节拍识别等核心算法,fig文件提供可视化操作界面,wav文件则收录了不同音高、速度的钢琴片段用于验证效果。目前已有253人学习下载。该工具综合运用数字信号处理和乐理知识,能将频率信息映射为对应乐符,并兼顾调性、音阶与和弦结构,形成完整转换流程。对希望研究音频分析算法或扩展和弦识别、多音轨转录等功能的使用者而言,这套带源码、界面和演示数据的MATLAB项目无疑是兼具实用与学习价值的宝贵资源。

1. 从一段录音到一张钢琴谱,MATLAB 能省下什么

你手里有一段钢琴曲的.wav录音,想把它转成能看、能改、能打印的乐谱。传统做法是戴上耳机,一个音一个音地扒,慢的曲子半小时,快的曲子一下午。如果这个活儿交给 MATLAB,思路就变成了一条清晰的处理链:读入音频文件,通过短时傅里叶变换提取频谱,再从频谱峰值中算出每一时刻的音高,最后把这些音高映射成 MIDI 音号,渲染成钢琴卷帘图或者导出成标准乐谱文件。整个过程绕开了任何第三方商用软件,也不要求你懂乐理——只要信号处理的基本概念就能跟下来。

这个领域在技术圈里叫自动乐谱转写(Automatic Music Transcription,AMT),但在工程落地上,我们不需要做一个能识别复杂和弦的 AI 模型,用经典的数字信号处理方法就足够处理大多数单音旋律和简单的多音片段。读完你会得到一套能在本地跑通的最小实现:输入一个.wav文件,输出一张标好音名和八度的钢琴卷帘图,以及一份可选导出的 MIDI 文件。适合正在做音频处理课程设计的人,也适合想给录音工具加一个“转乐谱”功能的开发者。

2. 音高检测:先把音频文件变成“随时间变化的频率”

2.1 为什么不用直接读文件的时域波形

很多人拿到音频文件的第一反应是画时域波形,看振幅高低。但振幅只告诉你什么时候响、响了多久,回答不了“这个音唱的是 C 还是 G”。钢琴的中央 C(C4)频率是 261.63 Hz,高一个八度的 C5 是 523.25 Hz,这些信息藏在信号的频率成分里,必须做频域变换才能拿出来。

MATLAB 里最常用的频率分析工具是短时傅里叶变换(STFT),对应函数是spectrogramstft。它把一段长信号切成很多小窗,每个窗做一次傅里叶变换,得到“时间-频率”二维矩阵。这个矩阵其实就是钢琴卷帘图的原型:横轴是时间,纵轴是频率,颜色深浅代表该频率成分的强度。接下来要做的就是从每一列里找出最强的频率峰,这个峰对应的频率就是当前时刻的音高。

% 读取音频文件,Audio Toolbox 或 MATLAB 基础版均可支持 [x, fs] = audioread('piano_single.wav'); x = x(:, 1); % 如果是立体声,只取左声道 x = x / max(abs(x)); % 归一化到 [-1, 1] % STFT 参数设计 winLen = 4096; % 窗长,采样率 44100 时对应约 93ms hopLen = 512; % 帧移,对应约 11.6ms nfft = 8192; % FFT 点数,补零提高频率分辨率 [S, f, t] = stft(x, fs, 'Window', hann(winLen, 'periodic'), ... 'OverlapLength', winLen - hopLen, 'FFTLength', nfft);

这段代码里,stft返回三个变量:S是复频谱矩阵,f是频率轴向量,t是时间轴向量。窗长 4096 在 44.1kHz 采样率下对应约 93ms,对钢琴音来说能覆盖最低音的若干个周期,又不会让瞬态被过度平滑。帧移 512 表示每 11.6ms 取一次“快照”,够用来分辨快速的十六分音符。nfft设为 8192 是为了让频率轴更细——谱线之间的间隔等于 44100/8192 ≈ 5.38Hz,比默认的 4096 点更精确。

2.2 峰值搜索与频率到音名的映射

拿到S之后,每一列取模值,在感兴趣的频率范围内(比如 80Hz 到 2000Hz,覆盖钢琴最低的 A0 到高音区)找最大值。那个最大值所在的位置就是当前帧的估计频率。但直接砍掉最大值的做法有隐患:倍频成分往往比基频更强,尤其是低音区,钢琴的 2 倍频和 3 倍频能量常常盖过基频。一个常用的缓解方案是“谐波积谱”法——把频谱压缩 2 倍、3 倍后叠加,能让基频处的峰被增强。

下面是完整的音高序列提取循环:

fMin = 80; % 最低音约 E2,对应 82.41Hz fMax = 2000; % 最高音调到 D7 附近足够 freqRange = (f >= fMin) & (f <= fMax); specMag = abs(S(freqRange, :)); freqAxis = f(freqRange); % 谐波积谱参数:压缩倍数越多越稳健,但计算量线性增加 numHarms = 4; pitchSeq = zeros(size(specMag, 2), 1); for col = 1:size(specMag, 2) hps = specMag(:, col); for h = 2:numHarms downsampled = specMag(1:h:end, col); if length(downsampled) < length(hps) hps = hps(1:length(downsampled)) .* downsampled; end end [~, idx] = max(hps); pitchSeq(col) = freqAxis(idx); end

逐帧算完,pitchSeq就是整段录音的音高轨迹。里面会夹杂静音帧的随机峰值和泛音干扰,下一步要做中值滤波去毛刺,再根据振幅包络判断哪些帧是有效音符。

2.3 用中值滤波和能量门限清洗音高轨迹

原始音高序列里有三种典型噪声:静音段随机取到的频率、音符切换时的滑音过渡、以及个别帧的异常跳变。中值滤波对孤立跳变非常有效,MATLAB 里一行movmedian就够。默认窗长 7 帧(约 81ms)既不会抹掉快速的波音,也能消掉大部分毛刺。

% 中值滤波平滑音高轨迹 pitchSmooth = movmedian(pitchSeq, 7); % 根据帧能量动态判定是否算有效音符 frameEnergy = sum(specMag.^2, 1); energyThresh = 0.3 * max(frameEnergy); % 相对阈值,适应不同录音音量 activeIdx = frameEnergy > energyThresh; % 能量低于阈值的帧,音高置为 NaN 表示休止 pitchFinal = pitchSmooth; pitchFinal(~activeIdx) = NaN;

能量阈值用“最大帧能量的 30%”而不是固定绝对数值,这是实测下来对不同音量录音适应性更好的做法。如果录音本身底噪很大,可以先把frameEnergy做一次movmean平滑再算阈值,避免某个突发噪声帧把阈值抬高太多。

3. 从音高序列到钢琴音符:分段、量化与音符合并

3.1 把连续音高切成离散音符

经过第 2 章的检测,pitchFinal是一个长度等于帧数的数组。钢琴谱的本质是离散事件:一个键按下的时刻、持续时长、力度。所以要把连续的音高轨迹切成一段一段的“音符”。最常见的切割依据是音高变化量——连续几个帧的音高差超过一个阈值,就认为进入了新的音符。钢琴的滑音和踏板造成的音高漂移会干扰这个策略,因此实际操作中需要加一个“最短音符时长”约束:小于 60ms 的片段直接并入相邻音符,避免切出大量十六分音符的碎片。

% 音高序列转音符事件列表 midiSeq = 69 + 12 * log2(pitchFinal / 440); midiSeq = round(midiSeq); % 量化到最近半音 noteOnsets = []; noteOffsets = []; minNoteLen = round(0.06 / (hopLen / fs)); % 最短 60ms 对应的帧数 % 找音高跳变的位置 jumpIdx = find(abs(diff(midiSeq)) >= 1); jumpIdx = [0; jumpIdx; length(midiSeq)]; for k = 1:length(jumpIdx) - 1 segStart = jumpIdx(k) + 1; segEnd = jumpIdx(k + 1); segLen = segEnd - segStart + 1; if segLen >= minNoteLen onsetTime = (segStart - 1) * hopLen / fs; offsetTime = segEnd * hopLen / fs; midiVal = mode(midiSeq(segStart:segEnd)); % 取众数避免边缘毛刺影响 noteOnsets(end + 1) = onsetTime; noteOffsets(end + 1) = offsetTime; noteMidi(end + 1) = midiVal; end end

这里的midiSeq转换公式是69 + 12*log2(f/440),69 对应中央 C 上方的 A4。量化成整数后,每个半音之间的最小间隔是 1,因此用abs(diff) >= 1找边界是合逻辑的。mode取众数是为了防止段内一两帧的检测偏差导致音高取错。如果你的录音里有颤音,这个逻辑会把同一段颤音切成多个音符,后面第 5 章会讲怎么缓解。

3.2 力度估计:用 RMS 包络算音符强度

音符建好了,还差力度。钢琴谱里的强弱记号(p、f、ff)对应 MIDI 速度值 1 到 127。工程上最简单的做法是对每个音符时区内所有帧的 RMS 取平均,然后线性映射到一个合理的 MIDI 力度范围。之所以不直接用峰值振幅,是避免单个噪声尖峰把力度顶到很高的位置。

velocityValues = zeros(1, length(noteOnsets)); for k = 1:length(noteOnsets) fStart = round(noteOnsets(k) / (hopLen / fs)) + 1; fEnd = round(noteOffsets(k) / (hopLen / fs)); fStart = max(1, fStart); fEnd = min(length(frameEnergy), fEnd); rmsVal = sqrt(mean(specMag(:, fStart:fEnd).^2, 'all')); velocityValues(k) = round(20 + 100 * (rmsVal / maxRms)); velocityValues(k) = min(127, max(1, velocityValues(k))); end

映射公式里的 20 和 100 是偏移量和缩放系数:保证最小的音也有 20 的力度不会让 MIDI 回放时听不见,最大的音不会超过 127 溢出。maxRms建议取整段录音 RMS 的 90 分位数,而不是最大值,否则单个爆音会把所有正常音符的力度都压缩得很低。

3.3 音符量化:吸附到最近的节拍网格

碎音符问题的另一个来源是节奏不精准。如果演奏者略有抢拍或拖拍,切出来的音符偏移量会参差不齐。量化操作解决这个问题——把每个音符的起始时间和结束时间吸附到最近的节拍网格上。假设预计的节奏是每分钟 120 拍,那么一拍是 0.5 秒,一个四分音符占 0.5 秒,八分音符是 0.25 秒,十六分音符是 0.125 秒。

bpm = 120; beatDur = 60 / bpm; quantUnit = beatDur / 4; % 十六分音符时长 noteOnsetsQ = round(noteOnsets / quantUnit) * quantUnit; noteOffsetsQ = round(noteOffsets / quantUnit) * quantUnit; noteOffsetsQ = max(noteOffsetsQ, noteOnsetsQ + quantUnit);

量化后,音符的时值会变成十六分音符的整数倍。这段代码隐含了一个前提:你已经知道目标 BPM。现实情况是演奏者不一定是严格的机器,开头可以从音符间隔的众数估计出一个整体 BPM,这里给出最直接的手动设定方式,适合你先拿一段节拍器录出来的音频做验证。

4. 结果可视化与 MIDI 导出:把“分析出来的数据”变成“能用的乐谱”

4.1 钢琴卷帘图:最直观的乐谱中间表示

纯数据列表(音符起始、结束、力度)不直观。第一件要做的事是画钢琴卷帘图——横轴时间、纵轴音高、每个音符画成一个矩形条,矩形高度代表时值长度,颜色深浅代表力度。这在 MATLAB 里只需要几行rectangle调用:

figure('Color', 'w'); hold on; for k = 1:length(noteOnsetsQ) yPos = noteMidi(k) - 0.4; % 矩形中心对齐音高线 rectH = 0.8; rectW = noteOffsetsQ(k) - noteOnsetsQ(k); colorIntensity = velocityValues(k) / 127; rectangle('Position', [noteOnsetsQ(k), yPos, rectW, rectH], ... 'FaceColor', [0.2 0.4 colorIntensity], ... 'EdgeColor', 'k', 'LineWidth', 0.5); end % 标注 C 和 C# 行 for octave = 2:7 yline(12 * (octave + 1) + 0, '--', 'C' + string(octave)); end

颜色映射里,力度越大蓝色分量的值越接近 1,得到的矩形颜色越偏亮蓝。这里为了显示方便把 MIDI 音高与 Y 轴对齐,实际如果想让 Y 轴显示成“C4、D4”这样的音名,需要额外写一个坐标轴刻度转换函数,替换yticklabel即可。卷帘图最大的价值是能在 10 秒内看出转写结果是否合理:音符有没有错位、时值有没有被切碎、高低音区对不对。

4.2 导出标准 MIDI 文件

MATLAB 基础环境没有直接写 MIDI 的函数,但 Audio Toolbox 里没有,基础版可以用一种更通用的做法——用字符串拼接.mid文件的标准二进制格式。MIDI 格式 1 的结构不算复杂,但手写要处理可变长数值,容易踩坑。一个更可靠的路径是寻找第三方工具函数,但这里有个更稳妥的方案:直接用 MATLAB 的writemidi(如果你装了 Audio Toolbox),或者封装一个最小的格式 0 写入器。

% 使用 Audio Toolbox 的 writemidi(R2021b 及以上版本) % 构造 note 事件矩阵:第1行起始拍数,第2行结束拍数,第3行通道,第4行音高,第5行力度 notes = zeros(5, length(noteOnsets)); for k = 1:length(noteOnsets) notes(1, k) = noteOnsetsQ(k) / beatDur; % 转成拍数 notes(2, k) = noteOffsetsQ(k) / beatDur; notes(3, k) = 0; % 通道 0,钢琴音色 notes(4, k) = noteMidi(k); notes(5, k) = velocityValues(k); end writemidi(notes, 'piano_output.mid');

writemidi的输入矩阵格式和标准 MIDI Toolbox 一致:第一行是音符起始位置(以拍为单位),第二行是结束位置,第三行是通道号,第四行是 MIDI 音高,第五行是力度。需要补充说明的是,writemidi在旧版本 MATLAB 里可能位于 File Exchange 的midi工具箱里没被官方收录。如果没装,你还有一条路,就是生成 MusicXML——纯文本格式,直接用fprintf写音符列表,能被 MuseScore 打开后转成标准五线谱。

% 简化版 MusicXML 生成框架 fid = fopen('piano_output.xml', 'w'); fprintf(fid, '<?xml version="1.0" encoding="UTF-8"?>\n'); fprintf(fid, '<score-partwise version="3.1">\n'); fprintf(fid, ' <part id="P1">\n'); for k = 1:length(noteOnsets) fprintf(fid, ' <note>\n'); fprintf(fid, ' <pitch><step>%s</step><octave>%d</octave></pitch>\n', ... stepName(noteMidi(k)), octaveFromMidi(noteMidi(k))); fprintf(fid, ' <duration>%d</duration>\n', ... round((noteOffsetsQ(k) - noteOnsetsQ(k)) / quantUnit)); fprintf(fid, ' <velocity>%d</velocity>\n', velocityValues(k)); fprintf(fid, ' </note>\n'); end fprintf(fid, ' </part>\n</score-partwise>\n'); fclose(fid);

这个简化版省略了时值类型(四分音符、八分音符等)和调号拍号,但大部分乐谱软件会自动推断缺失字段。最终你得到一个能被计算机播放的 MIDI 和一个能被人类阅读的 MusicXML 草稿,这就是从音频文件到“实际乐谱表”的两条落地路径。

5. 和弦与混响场景的 3 个实用修正技巧

5.1 动态阈值替代固定百分比阈值

第 2.3 节的能量阈值用的是0.3 * max(frameEnergy),这在单音旋律上够用,但遇到一个有延音踏板的录音时,某个和弦的尾音能量会拖得很长,导致后面紧跟的弱音被判定为休止。改进做法是给阈值加一个“动态地板”——把整段能量的 30 分位数当成底噪,再用最大值乘以 0.2 作为高阈值,两者取较大值。这样既保留了强音之后的余响检测,又不会让噪声段被误判成音符。

floorEnergy = prctile(frameEnergy, 30); energyThresh = max(0.2 * max(frameEnergy), floorEnergy * 3); activeIdx = frameEnergy > energyThresh;

floorEnergy * 3的经验值是试出来的:低于这个值,录音里的空调声、电流声会被认成音符;高于这个值,延音踏板的 p 力度弱音会被截断。这个参数和录音环境强相关,建议在脚本里做成变量,方便批量调参。

5.2 对低音区的倍频误判做加权修正

钢琴低音 A1(55Hz)的 FFT 频谱上,基频峰的幅度往往没有二次谐波(110Hz)高。第 2.2 节的谐波积谱法能缓解这个问题,但如果你只取numHarms = 2,效果会打折扣。我一般会把低音区(频率低于 150Hz)的搜索范围加一个指数权重:

weightFactor = (freqAxis / 150) .^ 0.5; weightFactor(freqAxis >= 150) = 1; specWeighted = specMag .* weightFactor;

这相当于告诉算法:低于 150Hz 的区域,优先相信基频附近的峰,而不是直接按能量大小找最大峰。这个技巧对钢琴包络衰减快、泛音丰富的特点非常有效,但对吉他这类谐波结构不同的乐器需要调回numHarms = 5配合使用。

5.3 验证转写结果的音频回放对照法

转写结果对不对,眼睛看不出来,耳朵能。在 MATLAB 里可以反方向合成一个音轨:按检测出的音符序列依次生成正弦波叠加,然后和原始录音做 A/B 对比。如果合成旋律听起来和原曲一致,说明音高序列基本准确;如果音符时长对不上节奏,问题多数出在minNoteLenquantUnit的设定上。

synthAudio = zeros(ceil(max(noteOffsetsQ) * fs), 1); for k = 1:length(noteOnsetsQ) sIdx = round(noteOnsetsQ(k) * fs) + 1; eIdx = round(noteOffsetsQ(k) * fs); f0 = 440 * 2^((noteMidi(k) - 69) / 12); tVec = (0:(eIdx - sIdx)) / fs; synthAudio(sIdx:eIdx) = synthAudio(sIdx:eIdx) + ... velocityValues(k) / 127 * sin(2 * pi * f0 * tVec); end sound(synthAudio, fs); % 播放合成音,跟原曲做直觉对比

这段代码生成的滤波是纯正弦波,音色很“电子”,但在核对音符序列的准确性上足够用。如果某个音符不对,你可以顺手在noteMidi里手动改成正确值再重新合成,这种交互方式比反复调整参数更快。最后要记住的是:这套流程处理单音旋律准确率会很高,处理密集和弦时会暴露出频谱重叠的问题,那种场景就该上深度学习模型了——但那是另一篇文章的主题。

最后收个尾:把numHarmsenergyThreshquantUnit三个参数做成一个结构体放进函数里,每次处理新录音时只需要做一次参数微调,然后整个批处理就能稳定运行。这套基于 STFT + 谐波积谱 + 音符分段的方案,就是一台不依赖任何外部标注数据的“轻量级自动扒谱机”。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询