从MIDI文件提取实际音符起始时间:解析器节拍时长适配问题
优化MIDI文件解析器:修复BPM与拍号处理问题
原代码核心问题分析
- 时间计算逻辑错误:
- 仅用当前
current_tempo转换所有累积ticks,忽略了tempo变化对历史ticks的影响——不同时间段的ticks必须用对应时刻的tempo转换。 - 注释掉了
last_msg_time的更新逻辑,导致delta_time计算完全失效,无法正确识别音符的时间间隔。
- 仅用当前
- 完全忽略拍号处理:
- 未解析
time_signature元消息,无法根据拍号(如4/4、3/4)划分正确的节拍边界,导致beat分组混乱。
- 未解析
- 累积ticks管理不当:
- 所有非元消息的time都累积到
accumulated_ticks,没有结合tempo变化分段处理,时间转换必然失真。
- 所有非元消息的time都累积到
优化方案与代码实现
以下是修复后的完整代码,解决了BPM缩放异常和拍号处理问题,同时保证输出结构符合需求:
import os import mido def midi_to_array(src_folder): files = [f for f in os.listdir(src_folder) if f.endswith(('.mid', '.midi'))][-5:] songs_data = [] for file in files: mid = mido.MidiFile(os.path.join(src_folder, file)) tracks = [] for track in mid.tracks: # 初始化轨道基础数据 track_name = "" track_beats = [] # 跟踪时间相关状态:当前tick位置、已转换的总毫秒数、tempo变化记录 current_tick = 0 total_ms = 0.0 tempo_events = [(0, 500000)] # (tick位置, tempo值,单位微秒/拍) # 跟踪拍号:默认4/4拍 time_sig_num = 4 time_sig_den = 4 # 当前beat的临时存储 current_notes = [] last_beat_end_ms = 0.0 # 第一步:先扫描轨道,收集所有tempo和拍号变化事件 for msg in track: current_tick += msg.time if msg.type == 'track_name': track_name = msg.name.strip().lower() elif msg.type == 'set_tempo': tempo_events.append((current_tick, msg.tempo)) elif msg.type == 'time_signature': time_sig_num = msg.numerator time_sig_den = msg.denominator # 重置tick位置,重新扫描处理音符 current_tick = 0 total_ms = 0.0 tempo_idx = 0 # 当前使用的tempo事件索引 current_tempo = tempo_events[0][1] # 计算每个节拍对应的tick数(基于拍号和ticks_per_beat) ticks_per_beat = mid.ticks_per_beat # 拍号分母是2的幂,转化为四分音符的倍数:如8分音符=0.5个四分音符 beat_unit = 2 ** (time_sig_den - 2) next_beat_tick = ticks_per_beat * time_sig_num * beat_unit for msg in track: # 先处理当前消息的tick增量,转换为实际毫秒 if msg.time > 0: # 检查是否需要切换tempo while tempo_idx + 1 < len(tempo_events) and current_tick + msg.time >= tempo_events[tempo_idx+1][0]: # 计算到下一个tempo变化点的tick数 delta_tick = tempo_events[tempo_idx+1][0] - current_tick # 转换为毫秒并累加 total_ms += mido.tick2second(delta_tick, ticks_per_beat, current_tempo) * 1000 current_tick = tempo_events[tempo_idx+1][0] tempo_idx += 1 current_tempo = tempo_events[tempo_idx][1] # 处理剩余的tick delta_tick = msg.time - (tempo_events[tempo_idx+1][0] - current_tick if tempo_idx+1 < len(tempo_events) else 0) total_ms += mido.tick2second(delta_tick, ticks_per_beat, current_tempo) * 1000 current_tick += msg.time # 处理note_on消息(忽略velocity=0的情况,视为note_off) if msg.type == 'note_on' and msg.velocity > 0: current_notes.append([msg.note % 12, msg.note // 12]) # 检查是否到达节拍边界(基于拍号的tick间隔) while current_tick >= next_beat_tick: # 计算当前beat的时长(到下一个beat的间隔) delta_ms = total_ms - last_beat_end_ms if current_notes: track_beats.append({ 'notes': current_notes.copy(), 'delta_ms': delta_ms }) # 更新状态 last_beat_end_ms = total_ms current_notes.clear() next_beat_tick += ticks_per_beat * time_sig_num * beat_unit # 添加最后一个未完成的beat if current_notes: delta_ms = total_ms - last_beat_end_ms track_beats.append({ 'notes': current_notes, 'delta_ms': delta_ms }) # 组装轨道数据 tracks.append({ 'track_name': track_name, 'track_beats': track_beats }) songs_data.append(tracks) return songs_data
关键改动说明
- 分段时间转换:
- 先扫描轨道收集所有
set_tempo事件,记录每个tempo生效的tick位置。 - 处理消息时,按tempo变化点分段计算tick到毫秒的转换,确保每个时间段用正确的tempo计算时间。
- 先扫描轨道收集所有
- 拍号处理:
- 解析
time_signature元消息,计算每个节拍对应的tick数,以此为依据划分beat边界。 - 支持不同拍号(如3/4、6/8)的节拍分组,符合音乐逻辑。
- 解析
- 时间状态修正:
- 新增
last_beat_end_ms跟踪节拍结束时间,正确计算每个beat到下一个beat的毫秒间隔delta_ms。 - 区分
note_on消息中velocity=0的情况(视为note_off),避免无效音符加入。
- 新增
- 输出结构调整:
- 每个beat包含
notes(半音+八度)和delta_ms(到下一节拍的实际毫秒间隔),完全符合需求。
- 每个beat包含
内容的提问来源于stack exchange,提问作者Maxim Mashkov
相关产品推荐
相关产品推荐

