
简介这是一款基于MATLAB开发的音频转钢琴乐谱工具面向音乐制作人、作曲者、音乐教育者以及对信号处理感兴趣的MATLAB开发者可帮助用户从音频文件中提取旋律、节拍与音符信息并生成可视化乐谱。资源内共45个文件其中18个wav音频样本用于测试与校准17个m源码文件覆盖自动相关法、FFT简化实现、正弦波生成等核心算法8个fig图形界面文件展示交互结果另含README说明与prj工程文件整体包体约9.37MB。项目代码结构清晰主分支包含音频读取、频谱分析、音高检测、节奏识别到乐谱输出的完整链路适合用于学习数字信号处理、傅里叶变换及音乐理论的实际应用。目前已有253人学习下载既可作为课程设计参考也能在此基础上扩展调性、和弦识别等功能是理解音频分析并落地为音乐工具的优质实例。1. 从波形到五线谱MATLAB 音频转钢琴谱到底在解决什么问题任何一个做过音频处理的人拿到一段钢琴曲的 .wav 文件第一反应大概率不是「好听」而是「这段旋律的音高序列是什么、节奏怎么切分、能不能直接落成谱子」。传统做法是用耳朵听写或者拖进 DAW 里对着频谱手动标音头碰上快速音阶或复杂和弦就非常痛苦。用 MATLAB 做这件事核心不是让程序「理解音乐」而是用信号处理的办法把三类信息从波形里剥出来音高对应钢琴键编号、音头时刻对应节拍位置、音符时长对应时值。搞清这三个量就能映射到钢琴的 88 键音名表和五线谱的时值体系上。这套方案特别适合三类人一是刚接触 MATLAB 音频处理、想找一个完整项目练手的学生二是需要批量给教学音频生成参考谱的音乐老师或扒谱爱好者三是做音乐信息检索MIR研究、需要自己实现 pitch detection 做对照实验的工程师。今天这篇文章会从频域变换的选型开始一直讲到时值量化、MIDI 生成和 UI 封装全程给出可以在 MATLAB R2023b 及之后版本直接运行的代码。需要先说明的是这不会是一个一步到位的商业级扒谱软件而是一套模块清晰、每个环节都可以单独替换算法的工程框架你可以在它的基础上换更好的基频检测算法或更聪明的节拍跟踪策略。2. 先解决「这个音是多高」短时傅里叶变换与基频估计的选型2.1 为什么需要分帧加窗而不是直接做整段 FFT一段钢琴演奏的 .wav 文件时长可能是几十秒而钢琴音高从 A0 的 27.5 Hz 到 C8 的 4186 Hz 跨越七个八度。直接对整段信号做 FFT得到的频谱是全部时间上的平均能量分布只能看出大致频率成分完全无法定位某个音出现在哪一刻。因此必须把信号切成短帧假设每一帧内信号近似平稳再逐帧做频域分析——这就是短时傅里叶变换STFT的基本思想。帧长和窗函数的选择是第一步要定的参数。常见做法是取 4096 个采样点作为帧长在 44.1 kHz 采样率下约 93 ms能覆盖到低音区一个完整周期。如果帧长太短比如 1024 点低频分辨率不够A027.5 Hz的周期约 36 ms一帧里连一个完整周期都装不下基频检测必然失败。我的建议是帧长定为 4096 或 8192窗函数用汉宁窗Hamming/Hann 均可但 Hann 窗的旁瓣衰减更好重叠率 75%对应跳跃步长 hop 1024 点。这样既保证低频分辨又不会让时间精度太差。[x, fs] audioread(piano_sample.wav); if size(x, 2) 1 x mean(x, 2); % 双声道转单声道 end x x / max(abs(x)); % 归一化避免后续阈值判断出错 winLen 4096; hop 1024; win hann(winLen, periodic); numFrames floor((length(x) - winLen) / hop) 1; X zeros(winLen/21, numFrames); for f 1:numFrames idx (f-1)*hop 1 : (f-1)*hop winLen; frame x(idx) .* win; spec fft(frame); X(:, f) abs(spec(1:winLen/21)); end freqAxis (0:winLen/2) * fs / winLen;这段代码把音频读入后先转单声道并归一化然后逐帧加窗做 FFT最终得到一个 2049×N 的幅度谱矩阵。这里的频点分辨率是 fs / winLen ≈ 10.77 Hz对低频音符来说足够分辨相邻半音A0 与 A#0 相差约 1.6 Hz仅靠幅度谱峰值并不能可靠区分后面会说明如何解决。2.2 基频检测为什么峰值拾取不够用YIN 算法怎么改进拿到每一帧的幅度谱后最直觉的做法是找幅度最大的频点把它当作基频。但实际效果很差钢琴的泛音往往比基频更强尤其是低音区第 3、4 次谐波的能量可能比基频高出数倍峰值拾取得到的往往是某个高次谐波导致音高识别整体高了一个甚至两个八度。另一个问题是相邻频点的量化误差10.77 Hz 的分辨率在 200 Hz 附近对应接近一个半音的偏差无法满足半音判定的需求。实际项目中我用得最多的是时域的 YIN 算法。YIN 的核心是计算差分函数再对差分函数做累积均值归一化CMND最后找第一个低于阈值的最小滞后值作为基音周期。相比频域的峰值拾取YIN 对泛音丰富的乐音更鲁棒而且精度可以做到亚半音级别。MATLAB 自带的 pitch 函数在 R2023b 中集成了 YIN 的实现但为了把参数讲清楚这里给出一个可自行修改的简化版本。function f0 yin_pitch(frame, fs, thresh) % frame: 单帧信号建议至少包含两个基频周期 % thresh: 阈值通常取 0.1~0.2 frame frame - mean(frame); N length(frame); d zeros(N/2, 1); for tau 1:N/2 diff frame(1:end-tau) - frame(1tau:end); d(tau) sum(diff.^2); end cmnd ones(N/2, 1); cum 0; for tau 2:N/2 cum cum d(tau); cmnd(tau) d(tau) * tau / cum; end % 找第一个低于阈值的极小值 candidates find(cmnd(2:end) thresh) 1; if isempty(candidates) [~, idx] min(cmnd); f0 fs / idx; else f0 fs / candidates(1); end end这段 YIN 实现的逻辑需要注意三点第一差分函数 d(tau) 的计算复杂度是 O(N²)帧长 4096 时尚可接受如果帧长加倍建议先用下采样减少计算量第二CMND 的累积平均是为了避免差分函数在短滞后处总有较小值导致周期估计偏长第三候选值取第一个低于阈值的滞后位置如果信号太弱或完全静音阈值判定会失效所以要加一个 min 兜底。实际使用中对于 27.5 Hz 的 A0fs / tau 算出的 f0 会落在 27~28 Hz 附近再通过最近半音映射确定音名。2.3 音高到琴键编号的映射规则钢琴的 88 个键从 A0 到 C8MIDI 编号从 21 到 108。任意频率 f 转 MIDI 编号的公式是 n 69 12 * log2(f / 440)其中 69 对应中央 C 上方的 A4440 Hz。检测到的 f0 转成 MIDI 编号后取整就得到了琴键位置。不过这里有一个关键工程问题f0 的估计误差会导致边界上的音高被取整到相邻半音。解决方法是先计算连续的浮点 MIDI 编号再设定一个 ±0.4 的容差范围如果取整后的小数部分超过 0.4 且落在两个半音之间就标记为「不确定音高」留到后处理阶段根据音符上下文校正。频域这边也不是完全不用。幅度谱矩阵 X 还有一个重要作用是计算每一帧的总能量和频谱质心spectral centroid前者用于判断该帧是否有音符发声后者用于区分钢琴的高低音区。如下代码在逐帧循环中同时拿到能量和 YIN 估计出的 f0形成原始的音符事件序列。frameCount floor((length(x) - winLen) / hop) 1; f0buf zeros(frameCount, 1); engBuf zeros(frameCount, 1); for f 1:frameCount idx (f-1)*hop 1 : (f-1)*hop winLen; frame x(idx) .* win; engBuf(f) sum(frame.^2); if engBuf(f) 0.005 % 能量阈值低于此值视为静音帧 f0buf(f) yin_pitch(frame, fs, 0.15); end end midiBuf zeros(frameCount, 1); for f 1:frameCount if f0buf(f) 0 midiBuf(f) 69 12 * log2(f0buf(f) / 440); end end能量阈值 0.005 是在归一化信号下的经验值实际应结合音频的录音响度调整。这里的 midiBuf 是一个浮点序列后续的第一步就是把这些浮点值量化成整数琴键编号并合并相邻的相同音高帧。3. 从音高序列到音符事件分段、去颤音与时值的归一化3.1 音符起止点的检测能量包络与音高稳定性的双重判断逐帧得到音高序列后还不能直接生成谱面。原因是钢琴音从按下到释放经历了攻击attack、衰减decay、延音sustain和释放release四个阶段YIN 算法在攻击段可能捕捉到不稳定的高次谐波在释放段可能因为信号变弱而输出随机频率。因此需要做两件事用能量包络找出音符的发声区间在区间内用音高稳定度剔除异常帧。音符起始检测最常见的做法是计算短时能量的一阶差分找到能量跃升超过一定倍数的位置作为候选 onset。也可以用频谱通量spectral flux即相邻帧幅度谱的正变化量之和它比单纯能量对音色变化更敏感。钢琴音头的频谱通量通常有一个明显的峰值。下面的代码结合了能量和频谱通量两路信号得到更稳健的 onset 候选点。specFlux zeros(numFrames-1, 1); for f 2:numFrames diffSpec max(0, X(:, f) - X(:, f-1)); specFlux(f-1) sum(diffSpec); end specFlux specFlux / max(specFlux); % 用能量包络的局部峰值定位 onset onsetIdx []; for f 2:length(specFlux)-1 if specFlux(f) 0.4 specFlux(f) specFlux(f-1) specFlux(f) specFlux(f1) if engBuf(f) 0.01 onsetIdx [onsetIdx; f]; end end end % 合并 3 帧以内的重复 onset mergedOnset []; for k 1:length(onsetIdx) if isempty(mergedOnset) || onsetIdx(k) - mergedOnset(end) 3 mergedOnset [mergedOnset; onsetIdx(k)]; end end阈值 0.4 和能量阈值 0.01 在实际音频上需要微调快速音阶段的频谱通量普遍偏高阈值可以提高到 0.5 以减少误触发录音噪声大时能量阈值要相应上调。合并窗口 3 帧对应约 70 ms小于这个间隔的触发大多来自同一个音符的攻击段。3.2 颤音与琶音的频域识别中值滤波和最小音符时长约束钢琴演奏中的颤音和同音反复是最容易让音高分段出错的两种情况。颤音是两个相邻音快速交替每帧检测到的音高在两者之间跳变同音反复则是在同一个音高上出现间隙。如果直接把每一个 onset 当作新音符颤音会被识别成大量时值极短的交替音符谱面显得支离破碎。对颤音我的处理方式是对 f0 序列做长度为 7 的中值滤波。中值滤波能消除持续时间极短小于 3 帧约 70 ms的跳变而真正的颤音持续时间较长滤波后会保留下来。这个方案不算完美但简单可靠。对同音反复则利用 onset 時刻和音高稳定性判断如果两个 onset 之间的音高差超过 0.4 个半音视为新音符不足 0.4 但间隔大于 120 ms视为同音反复记为两个音符并给一个小的分离标记间隔小于 120 ms 则直接拼接为一个音符。medianF0 medfilt1(midiBuf, 7, omitnan); durMin 120 / 1000 / (winLen/fs) * (winLen/hop); % 120ms对应帧数 % 实际在 hop1024, fs44100 下每帧约 23.2ms120ms 约 5.2 帧取 5 noteList []; for k 1:length(mergedOnset)-1 s mergedOnset(k); e mergedOnset(k1) - 1; if e - s durMin continue; end segMidi medianF0(s:e); segMidi segMidi(~isnan(segMidi)); if length(segMidi) 3 continue; end pitchVal round(median(segMidi)); noteList [noteList; s, e, pitchVal]; end这里 noteList 的每一行是 [起始帧, 结束帧, MIDI 编号]。median(segMidi)取区间内所有有效帧的中间值比均值更不容易被个别毛刺拉偏。PitchVal 取整后即琴键编号但不要丢掉原始浮点值后续做音高修正时能用到。3.3 时值量化把帧数换算成四分音符、八分音符和附点时值识别依赖两个信息音符持续的帧数以及音频的节拍速度BPM。如果音频本身没有 MIDI 参考或节拍轨最常见的做法是让用户指定 BPM或者自动估算。自动估算可以用 onset 间隔的直方图相同时值的音符间隔会反复出现取直方图的峰值对应的间隔作为一拍或半拍的基础单元。这里给出一个简单可靠的按比例量化方案默认用户提供 BPM程序把帧数换算成秒数再除以一拍时值得到以四分音符为单位的时值。bpm 120; beatSec 60 / bpm; % 每帧时长 frameSec winLen / fs * (winLen / hop); % 这里注意hop_of_frame hop / fs 才是帧间隔 frameDur hop / fs; noteDur (noteList(:,2) - noteList(:,1) 1) * frameDur; beats noteDur / beatSec; % 量化到最近的标准时值以四分音符1 stdDurations [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]; % 16分 8分 附点8分 四分 附点四分 二分 附点二分 全音符 beatsQ zeros(size(beats)); for i 1:length(beats) [~, idx] min(abs(beats(i) - stdDurations)); beatsQ(i) stdDurations(idx); if abs(beats(i) - beatsQ(i)) 0.3 beatsQ(i) round(beats(i) * 4) / 4; % 兜底量化到最近四分之一拍 end end量化误差 0.3 拍是允许的最大偏差。当一个音符的时值恰好落在两个标准时值正中间比如 0.875 拍程序允许它量化为附点八分音符0.75 拍或四分音符1 拍具体如何取舍可以结合它两侧音符的节奏形态做上下文判断。兜底逻辑round(beats*4)/4保证任何异常值最终都会落到 0.25 的整数倍上。节拍速度不准确会对时值量化产生灾难性影响——BPM 差 5% 就会让一个 1 拍的音符变成 0.95 或 1.05 拍。所以如果用户不确定 BPM推荐给程序一个区间让它扫描先按多种 BPM 分别量化统计量化残差的总和残差总和最小的 BPM 就是最优解。这个扫描法在上面的代码基础上加一个 for 循环即可不再重复实现。4. 把音符事件变成可读的乐谱MIDI 生成、五线谱绘制与自适应对齐4.1 为什么要同时输出 MIDI 和图像谱很多初学者以为「转成乐谱」就是画一张五线谱图片。但实际上五线谱的绘制涉及谱号、调号、拍号、临时升降号、连音线、符干方向等大量排布规则纯 MATLAB 实现完整的打谱引擎工作量很大也不必要。合理的架构是音符事件先转成 MIDI 文件MIDI 文件可以用任何打谱软件如 MuseScore、Sibelius打开并渲染成标准五线谱同时在 MATLAB 里用简单的绘制函数输出一张快速预览谱。MIDI 是数据层图像谱是查看层两者分开可以互相校验。MATLAB 从 R2023b 开始没有内置直接写 .mid 的标准库但 MIDI 文件格式非常简单完全可以手写一个写入函数。MIDI 文件由头部块和轨道块组成每个音符事件需要包含起始时间tick、音高、力度和时长。tick 的精度用 PPQNparts per quarter note表示常用 480这样一个四分音符等于 480 个 tick八分音符为 240。4.2 手写 MIDI 写入器与音符力度信息保留下面这个函数接受 noteList 和量化后的时值输出一个单轨 MIDI 文件。代码中把每个音符拆成 Note On 和 Note Off 两个事件力度统一取 80如果后续想保留动态信息可以把能量包络的峰值映射到 1~127 的力度值。function writeMidiFile(filename, noteListOnsets, noteListDurs, notePitches, ppqn) % filename: 输出 .mid 文件路径 % noteListOnsets: 每个音符的起始时刻秒 % noteListDurs: 每个音符的时值秒 % notePitches: MIDI 编号 % ppqn: 每四分音符 tick 数通常 480 ticksPerSec ppqn / (60 / bpm); % bpm 需要作为参数传入 % 实际函数请将 bpm 作为输入这里简写 trackData []; for i 1:length(notePitches) tOn round(noteListOnsets(i) * ticksPerSec); tOff tOn max(1, round(noteListDurs(i) * ticksPerSec)); % Note On: 状态 0x90, 音符, 力度 trackData [trackData; ... encodeVLQ(tOn), 0x90, notePitches(i), 80]; %#okAGROW trackData [trackData; ... encodeVLQ(tOff - tOn), 0x80, notePitches(i), 0]; end % 组装 MIDI 文件 header [double(MThd), ... typecast(uint32(6), uint8), ... typecast(uint16(0), uint8), ... % 格式0 typecast(uint16(1), uint8), ... % 音轨数 typecast(uint16(ppqn), uint8)]; % 分辨率 trackHeader [double(MTrk), ... typecast(uint32(length(trackData)), uint8)]; fid fopen(filename, w); fwrite(fid, header, uint8); fwrite(fid, trackHeader, uint8); fwrite(fid, trackData, uint8); fclose(fid); end这个编码器的关键点在于 encodeVLQ 函数——MIDI 的时间戳使用可变长度数量Variable-Length Quantity编码每个字节的低 7 位是有效数据最高位是延续标志。理论上这里需要单独实现一个 encodeVLQ 子函数将数值拆成 7 位一组从高到低写出。参数方面tOff - tOn作为 delta-time 写在 Note Off 事件之前表示上一个事件经过的 tick 数。力度 80 属于中强mezzo-forte如果力度信息不需要保留Note Off 的力度通常填 0 即可。4.3 在 MATLAB 里绘制简易五线谱谱号、小节线和音符位置图像预览谱用 MATLAB 的坐标轴和文本对象实现。五线谱的本质是五条平行线高斯音谱表从下加一线 C4 往上每线每间对应一个音高。这里以高音谱号为例绘制一个包含低音区到高音区的可伸缩五线谱视图。音符位置换算的逻辑是五线谱上相邻线或间的垂直距离对应一个全音步二度C4MIDI 60位于高音谱表下加一线往上每增加一个全音Y 坐标增加 0.5 个单位。function drawPianoRoll(noteOnsets, noteDurs, notePitches, bpm, fs, hop) % 用 piano roll 形式预览后续再叠加五线谱辅助线 figure(Name, Piano Roll Preview, Position, [100 100 1200 500]); hold on; durBeats noteDurs / (60/bpm); for i 1:length(notePitches) x0 noteOnsets(i) * bpm / 60; w durBeats(i); y0 notePitches(i) - 0.35; h 0.7; rectangle(Position, [x0 y0 w h], FaceColor, [0.3 0.6 0.9], EdgeColor, k); end ylim([55 80]); xlabel(小节数以4/4拍计, FontSize, 12); ylabel(MIDI 音符编号, FontSize, 12); % 画小节线 for m 0:floor(max(noteOnsets) * bpm / 60 / 4) xline(m * 4, --, Color, [0.6 0.6 0.6]); end % 标注中央 C yline(60, r, C4); % 五线谱辅助标记仅在 60 附近显示音名 for midi 55:80 text(-0.3, midi, midiToNoteName(midi), FontSize, 8, HorizontalAlignment, right); end end这段代码用rectangle画厂房卷帘窗piano roll比直接画五线谱更直观可以快速检查音符事件是否正确。如果确实需要画传统五线谱把rectangle换成在坐标 (x0, yPos) 处画椭圆符头再加上符干即可但工程量大不少。我一般建议用户先看 piano roll 确认音高和节拍再用生成的 MIDI 文件在专业打谱软件里排版这样效率最高。5. 多音检测与复杂录音的处理边界和弦、踏板和噪声的规避5.1 从单音走向和弦频谱峰值筛选与谐波能量分组到目前为止的算法都建立在「每一帧只有一个主音高」的假设上。但钢琴谱几乎必然包含和弦而单音 YIN 检测面对和弦时只能输出其中一个基频漏掉其他音会导致谱面缺音。检测和弦的常见做法是把 YIN 换成频域峰值拾取找出频谱中若干局部最大值用谐波关系判断哪些是真实基频而不是泛音。判断标准是如果一个候选峰值是另一个候选基频的整数倍且与谐波的频率误差在半音以内就把它标记为谐波而非独立音高。MATLAB 自带的 pitch 函数在 R2023b 中支持设置 Method 为 PEFprominence-based可同时返回多个候选音高。对于帧长 4096、采样率 44.1 kHz 的设置PEF 方法可以返回最多 3 个音高。下面的代码展示如何用 PEF 提取和弦帧的多个音高for f 1:numFrames idx (f-1)*hop 1 : (f-1)*hop winLen; frame x(idx) .* win; if sum(frame.^2) 0.005 [f0s, locs] pitch(frame, fs, Method, PEF, ... Range, [27.5 4186], MedianFilterLength, 1); % f0s 是一个或多个基频 for k 1:numel(f0s) if f0s(k) 0 midiVal 69 12 * log2(f0s(k) / 440); fprintf(Frame %d, Candidate %d: %.1f Hz, MIDI %.1f\n, ... f, k, f0s(k), midiVal); end end end endPEF 的 Range 参数要覆盖钢琴全音域 [27.5, 4186]。对于含和弦的帧输出可能同时包含两个真实基频和它们的高次谐波需要对谐波关系做后处理过滤。常见做法是把所有候选频点按 MIDI 编号排序对每一个频点检查它是否是某个更低频点的整数倍误差小于 0.5 个半音就剔除。5.2 延音踏板造成的音符重叠处理踩下延音踏板后钢琴的制音器全部抬起多个和弦的音会在时域上重叠交叠。此时即使检测到正确的音高音符的起止时间也会失真前一个和弦的音符会被检测成持续到下一个和弦因为踏板让声音自然衰减而非立即停止。对这种录音最好的策略是取消踏板区域的时值依据改用「以音头为准」的重叠分割法。具体做法是对每个检测到的 onset向前查找最近的前一个 onset把前一个音符的强制结束点定在当前位置往前 30 ms 处而不是让音符自然持续到能量耗尽。这相当于把所有音符的时值全部截断到下一个音头附近然后用量化逻辑重新赋予规范时值。这样处理对于踏板密集的流行钢琴曲效果很好但代价是八分音符与四分音符之间的区分完全依赖量化算法如果 BPM 不准会大量出错。% 强制音符结束位置为下一个音头前 30ms forcedEndSec onsetTimes(2:end) - 0.03; % 最后一个音符保留原始结束时间 forcedEndSec(end1) noteOriginalEnd(end); noteDurs forcedEndSec - onsetTimes; noteDurs(noteDurs 0.05) 0.05; % 最短不少于 50ms注意这 30 ms 的偏移量是经验值钢琴踏板产生的余音比正常收尾更漫长过大会误吞下一个音的短时值过小则会产生许多极短的幽灵音符。如果一段录音既包含踏板也包含无踏板段落可以考虑按能量衰减率分段处理——衰减率极低的段落视为踏板区。5.3 噪声、混响与低质量录音的前置处理链现实中的 .wav 文件大多不是干净的录音棚素材。电脑麦克风录的钢琴、手机录音的现场演奏、经过压缩的微信音频这些信号在进入音高检测之前最好先做一个前置处理链高通滤波去掉 30 Hz 以下的环境噪声和直流偏移、降噪如果噪声呈现平稳谱用谱减法、以及动态范围压缩把弱音放大到能被能量阈值检测到。这里给出一个最精简的处理链% 1. 高通滤波20Hz 以下滤除 [b, a] butter(4, 40 / (fs/2), high); x filtfilt(b, a, x); % 2. 去除直流偏置 x x - mean(x); % 3. 轻压缩幅度超过 0.6 的部分软削波 x tanh(1.5 * x) / tanh(1.5);40 Hz 高通会把钢琴最低音 A027.5 Hz的部分能量滤掉但 A0 的基频能量占比本来就小丢失后对 YIN 检测的影响通常可容忍——如果确认录音包含大量低音区内容高通频率应降到 20 Hz。tanh软削波会给信号引入一定谐波失真但能明显提升弱音帧的能量适合动态范围大的古典钢琴录音。预处理和执行 YIN 检测之间的参数耦合非常大——高通滤波和压缩会改变能量阈值的最佳设置建议每次改前置处理参数时用单独一段音频做对照测试不要盲调。6. 调不通时的一线排查技巧BPM 估计、半音偏差与 UI 面板联动6.1 用 onset 间隔直方图自动估计 BPM 的简易实现前面时值量化依赖用户输入 BPM但很多时候用户并不知道录音的实际速度。这里给出一个自动 BPM 估计的实现思路统计相邻 onset 间隔的时长单位秒取直方图 0.2 到 2 秒区间的峰值。0.2 秒对应 300 BPM 的四分音符2 秒对应 30 BPM基本覆盖钢琴曲的常见速度。直方图分箱宽度取 5% 的间隔范围避免离散误差。onsetSeconds mergedOnset * frameDur; intervals diff(onsetSeconds); intervals intervals(intervals 0.15 intervals 2.0); [counts, edges] histcounts(intervals, 50); [~, peakBin] max(counts); peakInterval (edges(peakBin) edges(peakBin1)) / 2; bpmEstimate 60 / peakInterval; % 但要注意 onset 间隔可能对应八分音符所以候选 bpm 应该是估计值的倍数 bpmCandidates [bpmEstimate/2, bpmEstimate, bpmEstimate*2]; % 取一个与标准音乐速度最接近的值 [~, bpmFinalIdx] min(abs(bpmCandidates - [60, 120, 180])); bpmFinal bpmCandidates(bpmFinalIdx);如果一段快速音阶的音符间隔是 0.25 秒那么直方图峰值在 240 BPM但实际音乐速度多半是 120 BPM每个音是八分音符。所以要把估计值除以 2 或乘以 2 后再比较当候选值为 60、120、180 时取最接近的值。这个方法假设速度范围 60~180 BPM 优先对大多数练习曲和流行钢琴基本成立。6.2 半音偏差的系统性偏移调律检测与移调修正如果录音来自调律不准的钢琴或者播放速度有微小变化的录音检测到的音高整体可能偏移 20~50 音分1 音分等于半音的 1/100。这种情况下音符之间的相对关系是对的但所有音比标准音高整体偏高或偏低。处理方式是检测整个音频的音高偏差分布找到众数偏移然后把这个偏移量从所有检测结果中减去。midiAll []; for f 1:numFrames if f0buf(f) 0 midiAll [midiAll; 69 12 * log2(f0buf(f)/440)]; %#okAGROW end end % 计算每个估计音高距离最近半音的偏差单位半音 devs midiAll - round(midiAll); globalOffset mode(round(devs * 100)) / 100; % 以音分为单位的众数 % 修正 midiCorrected round(midiAll - globalOffset);如果钢琴整体调低了 30 音分所有检测出的 MIDI 编号小数部分会稳定在 -0.3 附近众数偏移就是 -0.3 半音修正后的音符落到标准半音上。值得注意的是这个修正应该在量化之前做否则取整后的音符将丢失偏移信息。6.3 参数联动检查用可视化面板快速定位错误的检测阶段一个常见的排错场景是「明明旋律是对的但谱子多了一堆 32 分音符」。问题可能出在三个地方onset 检测过于灵敏把琴键弹奏的机械噪声当成音符、去除颤音中值窗口太小、或者 BPM 估计错误导致时值量化偏激。为了快速定位建议在开发阶段把一个测试音频的所有中间结果显示在同一个 figure 上tiledlayout(3, 1); nexttile; plot((0:length(x)-1)/fs, x); title(波形); hold on; for k 1:length(onsetTimes) xline(onsetTimes(k), r); end nexttile; plot(f0buf); title(原始 f0 序列Hz); hold on; plot(medfilt1(f0buf, 7), g); legend({raw, medfilt7}); nexttile; % 显示最终音符时值 for i 1:length(noteList) rectangle(Position, [noteOnsets(i), notePitches(i)-0.4, noteDurs(i), 0.8], ... FaceColor, y, EdgeColor, k); end xlabel(秒); ylabel(MIDI);三个子图联动检查可以做到「哪一层出了问题一目了然」波形上的红线如果是多余音符说明 onset 阈值太低f0 序列中的毛刺如果被 medfilt1 滤掉了但音符列表里还有短音说明分段逻辑不对最后一个子图如果显示的音符时长全部偏短检查 BPM 的输入。为了在开发时省去反复手动调整我建议把 BPM、能量阈值、YIN 阈值和 onset 阈值都做成一个可调参数的脚本入口用input函数或uifigure的滑块控件动态更新参数每次调整后立即刷新三子图。这个方法看起来简单却能让整个调试周期缩短一半以上。本文还有配套的精品资源点击获取