import os import numpy as np import matplotlib # 关键修复:防止界面环境下 Matplotlib 尝试开启 GUI 导致卡死 matplotlib.use('Agg') import matplotlib.pyplot as plt import pretty_midi from scipy.spatial.distance import cdist class MIDIPitchEvaluator: def __init__(self, original_midi_path, cover_midi_path, time_threshold=0.2, tolerance=0, unmatched_penalty_weight=0.05, time_deviation_weight=0.01, output_dir='results'): """初始化MIDI音准评价器 (Hugging Face 兼容版)""" self.original_midi_path = original_midi_path self.cover_midi_path = cover_midi_path self.time_threshold = time_threshold self.tolerance = tolerance self.minor_deviation_upper = 1 self.severe_deviation_lower = 2 self.unmatched_penalty_weight = unmatched_penalty_weight self.time_deviation_weight = time_deviation_weight # 确保输出目录存在 self.output_dir = output_dir os.makedirs(self.output_dir, exist_ok=True) # 存储分析结果 self.original_notes = None self.cover_notes = None self.note_mapping = None self.pitch_deviations = None self.start_time_deviations = None self.end_time_deviations = None self.total_time_deviations = None self.statistics = None self.matched_pairs = [] self.unmatched_cover_notes = 0 self.unmatched_original_notes = 0 def load_midi_files(self): try: self.original_midi = pretty_midi.PrettyMIDI(self.original_midi_path) self.original_notes = self._find_singing_voice_notes(self.original_midi) self.cover_midi = pretty_midi.PrettyMIDI(self.cover_midi_path) self.cover_notes = self._find_singing_voice_notes(self.cover_midi) if self.original_notes is None: if len(self.original_midi.instruments) > 0: self.original_notes = self.original_midi.instruments[0].notes else: return False if self.cover_notes is None: if len(self.cover_midi.instruments) > 0: self.cover_notes = self.cover_midi.instruments[0].notes else: return False return True except Exception as e: print(f"加载MIDI文件出错: {e}") return False def _find_singing_voice_notes(self, midi_obj): for instrument in midi_obj.instruments: if instrument.name.lower() == "singing voice": return instrument.notes return None def align_notes(self): if not self.original_notes or not self.cover_notes: return False self.note_mapping = {} self.start_time_deviations = [] self.end_time_deviations = [] self.total_time_deviations = [] self.matched_pairs = [] cover_starts = np.array([note.start for note in self.cover_notes]) cover_ends = np.array([note.end for note in self.cover_notes]) original_starts = np.array([note.start for note in self.original_notes]) original_ends = np.array([note.end for note in self.original_notes]) cover_pitches = np.array([note.pitch for note in self.cover_notes]) original_pitches = np.array([note.pitch for note in self.original_notes]) cover_time_features = np.column_stack((cover_starts, cover_ends)) original_time_features = np.column_stack((original_starts, original_ends)) time_dist_matrix = cdist(cover_time_features, original_time_features, metric='euclidean') pitch_dist_matrix = cdist(cover_pitches.reshape(-1, 1), original_pitches.reshape(-1, 1), metric='euclidean') for i in range(len(self.cover_notes)): candidates = np.where(time_dist_matrix[i] < self.time_threshold * np.sqrt(2))[0] if len(candidates) == 0: self.note_mapping[i] = None continue best_j = candidates[np.argmin(pitch_dist_matrix[i, candidates])] start_time_dev = self.cover_notes[i].start - self.original_notes[best_j].start end_time_dev = self.cover_notes[i].end - self.original_notes[best_j].end self.start_time_deviations.append(start_time_dev) self.end_time_deviations.append(end_time_dev) self.total_time_deviations.append(abs(start_time_dev) + abs(end_time_dev)) self.matched_pairs.append([i, best_j, 0, start_time_dev, end_time_dev]) self.note_mapping[i] = best_j self.matched_count = len(self.matched_pairs) self.total_cover_notes = len(self.cover_notes) self.unmatched_cover_notes = self.total_cover_notes - self.matched_count return True def calculate_pitch_deviations(self): if self.note_mapping is None: return False self.pitch_deviations = [] for i in range(len(self.matched_pairs)): cover_idx, original_idx = self.matched_pairs[i][0], self.matched_pairs[i][1] pitch_dev = self.cover_notes[cover_idx].pitch - self.original_notes[original_idx].pitch self.pitch_deviations.append(pitch_dev) self.matched_pairs[i][2] = pitch_dev return True def calculate_statistics(self): if not self.pitch_deviations: return False pitch_devs = np.array(self.pitch_deviations) total_matched = len(pitch_devs) minor_count = np.sum((np.abs(pitch_devs) > self.tolerance) & (np.abs(pitch_devs) <= self.minor_deviation_upper)) severe_count = np.sum(np.abs(pitch_devs) >= self.severe_deviation_lower) minor_ratio = minor_count / total_matched * 100 severe_ratio = severe_count / total_matched * 100 cover_unmatched_ratio = (self.unmatched_cover_notes / self.total_cover_notes * 100) total_time_total = np.sum(self.total_time_deviations) normalized_time_dev = total_time_total / (self.time_threshold * 2) if self.time_threshold > 0 else 0 time_penalty = min(normalized_time_dev * self.time_deviation_weight, 15) base_score = 100 - severe_ratio * 0.1 - minor_ratio * 0.2 unmatched_penalty = cover_unmatched_ratio * self.unmatched_penalty_weight total_penalty = unmatched_penalty + time_penalty overall_score = max(60, base_score - total_penalty) self.statistics = { 'total_matched_notes': total_matched, 'total_cover_notes': self.total_cover_notes, 'mean_abs_pitch_dev': np.mean(np.abs(pitch_devs)), 'accurate_pitch_percentage': np.sum(np.abs(pitch_devs) <= self.tolerance) / total_matched * 100, 'minor_off_percentage': minor_ratio, 'severe_off_percentage': severe_ratio, 'cover_unmatched_percentage': cover_unmatched_ratio, 'total_time_total': total_time_total, 'base_score': base_score, 'unmatched_penalty': unmatched_penalty, 'time_penalty': time_penalty, 'total_penalty': total_penalty, 'overall_score': overall_score } return True def _convert_time_to_srt_format(self, seconds): hours, rem = divmod(seconds, 3600) minutes, seconds = divmod(rem, 60) return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d},{int((seconds%1)*1000):03d}" def generate_srt_subtitle(self): if not self.matched_pairs: return False sorted_pairs = sorted(self.matched_pairs, key=lambda x: self.cover_notes[x[0]].start) srt_content = [] for i, (c_idx, o_idx, p_dev, s_dev, e_dev) in enumerate(sorted_pairs, 1): cn, on = self.cover_notes[c_idx], self.original_notes[o_idx] p_label = "准确" if abs(p_dev) <= self.tolerance else ("轻微走音" if abs(p_dev) <= self.minor_deviation_upper else "严重走音") text = (f"原唱: {pretty_midi.note_number_to_name(on.pitch)} | 翻唱: {pretty_midi.note_number_to_name(cn.pitch)} | " f"音高偏差: {p_dev:+}半音 ({p_label}) | 起始: {s_dev:+.3f}s | 结束: {e_dev:+.3f}s") srt_content.append(f"{i}\n{self._convert_time_to_srt_format(cn.start)} --> {self._convert_time_to_srt_format(cn.end)}\n{text}\n") out_file = os.path.join(self.output_dir, '音高与节奏对比字幕.srt') with open(out_file, 'w', encoding='utf-8') as f: f.write('\n'.join(srt_content)) return out_file def visualize_results(self): if self.statistics is None: return False try: plt.figure(figsize=(12, 6)) time_pts = [self.cover_notes[p[0]].start for p in self.matched_pairs] p_devs = [p[2] for p in self.matched_pairs] colors = ['green' if abs(d) <= self.tolerance else ('orange' if abs(d) <= self.minor_deviation_upper else 'red') for d in p_devs] plt.scatter(time_pts, p_devs, s=15, alpha=0.7, c=colors) plt.axhline(y=0, color='blue', linestyle='-', alpha=0.3) plt.title('Pitch Deviation Timeline') img_path = os.path.join(self.output_dir, '音高偏差时间序列图.png') plt.savefig(img_path) plt.close('all') return img_path except Exception as e: print(f"可视化失败: {e}") return None def evaluate(self, run_visuals=True): if not self.load_midi_files(): return False if not self.align_notes(): return False if not self.calculate_pitch_deviations(): return False if not self.calculate_statistics(): return False srt_file = self.generate_srt_subtitle() img_file = None if run_visuals: img_file = self.visualize_results() return { "statistics": self.statistics, "srt_file": srt_file, "plot_img": img_file } if __name__ == "__main__": # 本地测试代码保持不变,但 evaluate 返回字典 evaluator = MIDIPitchEvaluator( "midi_input/学不会-林俊杰-align_Vocals_basic_pitch.mid", "midi_input/学不会-zzc-align_Vocals_basic_pitch.mid" ) result = evaluator.evaluate(run_visuals=True) if result: print(f"最终得分: {result['statistics']['overall_score']:.1f}")