恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
MATLAB自动扒谱:从音频到钢琴卷帘图与MIDI的完整实现
首页
资讯中心
/
MATLAB自动扒谱:从音频到钢琴卷帘图与MIDI的完整实现
MATLAB自动扒谱:从音频到钢琴卷帘图与MIDI的完整实现
发布时间:2026/9/14 14:43:58
简介一份使用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 TranscriptionAMT但在工程落地上我们不需要做一个能识别复杂和弦的 AI 模型用经典的数字信号处理方法就足够处理大多数单音旋律和简单的多音片段。读完你会得到一套能在本地跑通的最小实现输入一个.wav文件输出一张标好音名和八度的钢琴卷帘图以及一份可选导出的 MIDI 文件。适合正在做音频处理课程设计的人也适合想给录音工具加一个“转乐谱”功能的开发者。2. 音高检测先把音频文件变成“随时间变化的频率”2.1 为什么不用直接读文件的时域波形很多人拿到音频文件的第一反应是画时域波形看振幅高低。但振幅只告诉你什么时候响、响了多久回答不了“这个音唱的是 C 还是 G”。钢琴的中央 CC4频率是 261.63 Hz高一个八度的 C5 是 523.25 Hz这些信息藏在信号的频率成分里必须做频域变换才能拿出来。MATLAB 里最常用的频率分析工具是短时傅里叶变换STFT对应函数是spectrogram或stft。它把一段长信号切成很多小窗每个窗做一次傅里叶变换得到“时间-频率”二维矩阵。这个矩阵其实就是钢琴卷帘图的原型横轴是时间纵轴是频率颜色深浅代表该频率成分的强度。接下来要做的就是从每一列里找出最强的频率峰这个峰对应的频率就是当前时刻的音高。% 读取音频文件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 的 writemidiR2021b 及以上版本 % 构造 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 version1.0 encodingUTF-8?\n); fprintf(fid, score-partwise version3.1\n); fprintf(fid, part idP1\n); for k 1:length(noteOnsets) fprintf(fid, note\n); fprintf(fid, pitchstep%s/stepoctave%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 对低音区的倍频误判做加权修正钢琴低音 A155Hz的 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 对比。如果合成旋律听起来和原曲一致说明音高序列基本准确如果音符时长对不上节奏问题多数出在minNoteLen或quantUnit的设定上。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里手动改成正确值再重新合成这种交互方式比反复调整参数更快。最后要记住的是这套流程处理单音旋律准确率会很高处理密集和弦时会暴露出频谱重叠的问题那种场景就该上深度学习模型了——但那是另一篇文章的主题。最后收个尾把numHarms、energyThresh、quantUnit三个参数做成一个结构体放进函数里每次处理新录音时只需要做一次参数微调然后整个批处理就能稳定运行。这套基于 STFT 谐波积谱 音符分段的方案就是一台不依赖任何外部标注数据的“轻量级自动扒谱机”。本文还有配套的精品资源点击获取