# -*- coding: utf-8 -*-
"""
MIDI转简谱 – 手机适配版
功能:导入MIDI文件,转换为简谱文本
适配:手机屏幕(小屏优化)
"""
import sys
import os
import re
import threading
from datetime import datetime
# 尝试导入依赖
try:
import mido
except ImportError:
print("请先安装 mido: pip install mido")
sys.exit(1)
try:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
except ImportError:
print("请先安装 tkinter (Python自带)")
sys.exit(1)
APP_NAME = "MIDI转简谱"
APP_VERSION = "1.0"
APP_AUTHOR = "叶海宁"
APP_EMAIL = "791687349@qq.com"
class MidiToJianpuMobile:
def __init__(self, root):
self.root = root
self.root.title(f"{APP_NAME} V{APP_VERSION}")
# ========== 手机适配设置 ==========
# 获取屏幕尺寸
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
# 设置窗口大小(手机适配:全屏或接近全屏)
if screen_width < 800: # 手机屏幕
win_width = screen_width – 20
win_height = screen_height – 40
self.is_mobile = True
self.font_size = 8
self.button_height = 2
self.padding = 5
else: # 平板或桌面
win_width = min(600, screen_width – 100)
win_height = min(800, screen_height – 100)
self.is_mobile = False
self.font_size = 8
self.button_height = 1
self.padding = 10
self.root.geometry(f"{win_width}x{win_height}")
self.root.minsize(320, 480)
# ========== 颜色主题(护眼模式) ==========
self.bg_color = "#f5f0e8"
self.fg_color = "#2c3e50"
self.accent_color = "#4CAF50"
self.danger_color = "#e74c3c"
self.primary_color = "#3498db"
self.root.configure(bg=self.bg_color)
# ========== 创建UI ==========
self.create_widgets()
# ========== 状态变量 ==========
self.current_file = None
self.converted_tracks = []
# ========== 状态栏 ==========
self.status_var = tk.StringVar(value="就绪 | 请导入MIDI文件")
status_bar = tk.Label(
root,
textvariable=self.status_var,
bd=1,
relief=tk.SUNKEN,
anchor=tk.W,
font=("微软雅黑", self.font_size – 2),
bg="#ecf0f1"
)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
def create_widgets(self):
"""创建UI组件 – 手机适配布局"""
# ========== 主容器 ==========
main_frame = tk.Frame(self.root, bg=self.bg_color)
main_frame.pack(fill=tk.BOTH, expand=True, padx=self.padding, pady=self.padding)
# ========== 标题 ==========
title_font = ("微软雅黑", self.font_size + 6, "bold")
title = tk.Label(
main_frame,
text=f"🎵 {APP_NAME}",
font=title_font,
fg=self.primary_color,
bg=self.bg_color
)
title.pack(pady=(10, 5))
subtitle = tk.Label(
main_frame,
text="导入MIDI文件 → 自动转换为简谱",
font=("微软雅黑", self.font_size – 2),
fg="#7f8c8d",
bg=self.bg_color
)
subtitle.pack(pady=(0, 15))
# ========== 按钮区域(横向布局) ==========
btn_frame = tk.Frame(main_frame, bg=self.bg_color)
btn_frame.pack(fill=tk.X, pady=5)
# 导入按钮
self.import_btn = tk.Button(
btn_frame,
text="📂 导入MIDI",
width=10,
command=self.import_midi_file,
bg=self.accent_color,
fg="white",
font=("微软雅黑", self.font_size),
height=self.button_height,
relief=tk.RAISED
)
self.import_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
# 复制按钮
self.copy_btn = tk.Button(
btn_frame,
text="📋 复制结果",
width=10,
command=self.copy_result,
bg=self.primary_color,
fg="white",
font=("微软雅黑", self.font_size),
height=self.button_height,
state=tk.DISABLED,
relief=tk.RAISED
)
self.copy_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
# 清空按钮
self.clear_btn = tk.Button(
btn_frame,
text="🗑️ 清空",
width=10,
command=self.clear_result,
bg=self.danger_color,
fg="white",
font=("微软雅黑", self.font_size),
height=self.button_height,
relief=tk.RAISED
)
self.clear_btn.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
# ========== 音轨选择区域 ==========
track_frame = tk.Frame(main_frame, bg=self.bg_color)
track_frame.pack(fill=tk.X, pady=5)
tk.Label(
track_frame,
text="选择音轨:",
font=("微软雅黑", self.font_size),
bg=self.bg_color
).pack(side=tk.LEFT, padx=2)
self.track_var = tk.StringVar(value="全部")
self.track_combo = ttk.Combobox(
track_frame,
textvariable=self.track_var,
values=["全部"],
state="readonly",
font=("微软雅黑", self.font_size),
width=15 if self.is_mobile else 20
)
self.track_combo.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2)
self.track_combo.bind("<<ComboboxSelected>>", self.on_track_selected)
# ========== 结果显示区域 ==========
result_frame = tk.Frame(main_frame, bg=self.bg_color)
result_frame.pack(fill=tk.BOTH, expand=True, pady=5)
# 标签
label_frame = tk.Frame(result_frame, bg=self.bg_color)
label_frame.pack(fill=tk.X)
self.info_label = tk.Label(
label_frame,
text="💡 请导入MIDI文件查看转换结果",
font=("微软雅黑", self.font_size – 1),
fg="#7f8c8d",
bg=self.bg_color
)
self.info_label.pack(side=tk.LEFT)
# 字符计数
self.count_label = tk.Label(
label_frame,
text="",
font=("微软雅黑", self.font_size – 2),
fg="#95a5a6",
bg=self.bg_color
)
self.count_label.pack(side=tk.RIGHT)
# 文本框(带滚动条)
self.result_text = scrolledtext.ScrolledText(
result_frame,
wrap=tk.WORD,
font=("Consolas", self.font_size),
bg="white",
fg=self.fg_color,
relief=tk.FLAT,
bd=2,
highlightthickness=1,
highlightcolor=self.primary_color
)
self.result_text.pack(fill=tk.BOTH, expand=True)
# ========== 底部信息 ==========
footer_frame = tk.Frame(main_frame, bg=self.bg_color)
footer_frame.pack(fill=tk.X, pady=5)
footer_text = f"{APP_NAME} v{APP_VERSION} | 支持多音轨MIDI | 自动识别BPM"
tk.Label(
footer_frame,
text=footer_text,
font=("微软雅黑", self.font_size – 3),
fg="#bdc3c7",
bg=self.bg_color
).pack()
def update_status(self, message, is_error=False):
"""更新状态栏"""
if is_error:
self.status_var.set(f"❌ {message}")
else:
self.status_var.set(f"✅ {message}")
self.root.update_idletasks()
def import_midi_file(self):
"""导入MIDI文件"""
# 手机端使用文件选择器
midi_file = filedialog.askopenfilename(
title="选择MIDI文件",
filetypes=[
("MIDI文件", "*.mid"),
("MIDI文件", "*.midi"),
("所有文件", "*.*")
]
)
if not midi_file:
return
self.current_file = midi_file
self.update_status(f"正在解析: {os.path.basename(midi_file)}")
# 在新线程中处理,避免界面卡顿
threading.Thread(target=self._process_midi, args=(midi_file,), daemon=True).start()
def _process_midi(self, midi_file):
"""处理MIDI文件(在后台线程运行)"""
try:
tracks = self.midi_to_jianpu_multi_track(midi_file)
if not tracks:
self.root.after(0, lambda: self._show_error("MIDI文件中未找到音符"))
return
self.converted_tracks = tracks
# 更新音轨选择下拉框
track_names = ["全部"] + [f"音轨{i + 1}: {t['name']}" for i, t in enumerate(tracks)]
self.root.after(0, lambda: self._update_track_combo(track_names))
# 显示全部音轨结果
self.root.after(0, lambda: self._display_all_tracks())
# 启用复制按钮
self.root.after(0, lambda: self.copy_btn.config(state=tk.NORMAL))
self.root.after(0, lambda: self.update_status(
f"完成! 共 {len(tracks)} 个音轨 | {os.path.basename(midi_file)}"
))
except Exception as e:
self.root.after(0, lambda: self._show_error(f"转换失败: {str(e)}"))
def _show_error(self, message):
"""显示错误信息"""
self.update_status(message, is_error=True)
messagebox.showerror("错误", message)
def _update_track_combo(self, track_names):
"""更新音轨下拉框"""
self.track_combo['values'] = track_names
self.track_var.set("全部")
def _display_all_tracks(self):
"""显示所有音轨的简谱"""
if not self.converted_tracks:
return
result_parts = []
total_notes = 0
for i, track in enumerate(self.converted_tracks):
bpm = track.get('bpm', 120)
jianpu = track['jianpu']
total_notes += len(jianpu)
# result_parts.append(f"【音轨 {i + 1}: {track['name']}】")
# result_parts.append(f"速度: {bpm} BPM")
result_parts.append("[0,320,G大调]")
# result_parts.append("")
# 格式化简谱(每80字符换行)
for j in range(0, len(jianpu), 80):
result_parts.append(jianpu[j:j + 80])
result_parts.append("")
# result_parts.append("-" * 50)
result_parts.append("")
full_text = "\\n".join(result_parts)
self.result_text.delete("1.0", tk.END)
self.result_text.insert("1.0", full_text)
# 更新信息
self.info_label.config(text=f"🎵 共 {len(self.converted_tracks)} 个音轨")
self.count_label.config(text=f"音符数: {total_notes}")
def on_track_selected(self, event):
"""选择音轨时触发"""
selection = self.track_var.get()
if not self.converted_tracks:
return
if selection == "全部":
self._display_all_tracks()
return
# 解析音轨索引
try:
# 格式: "音轨X: 名称"
import re
match = re.search(r'音轨(\\d+)', selection)
if match:
idx = int(match.group(1)) – 1
if 0 <= idx < len(self.converted_tracks):
self._display_single_track(idx)
except:
pass
def _display_single_track(self, idx):
"""显示单个音轨"""
track = self.converted_tracks[idx]
bpm = track.get('bpm', 120)
jianpu = track['jianpu']
result_parts = []
# result_parts.append(f"【音轨 {idx + 1}: {track['name']}】")
# result_parts.append(f"速度: {bpm} BPM")
result_parts.append("[0,320,G大调]")
# result_parts.append("")
for j in range(0, len(jianpu), 80):
result_parts.append(jianpu[j:j + 80])
full_text = "\\n".join(result_parts)
self.result_text.delete("1.0", tk.END)
self.result_text.insert("1.0", full_text)
self.info_label.config(text=f"🎵 当前: {track['name']}")
self.count_label.config(text=f"音符数: {len(jianpu)}")
def copy_result(self):
"""复制结果到剪贴板"""
content = self.result_text.get("1.0", tk.END).strip()
if content:
self.root.clipboard_clear()
self.root.clipboard_append(content)
self.update_status("已复制到剪贴板")
# 手机端显示提示
messagebox.showinfo("成功", "简谱内容已复制到剪贴板")
def clear_result(self):
"""清空结果"""
self.result_text.delete("1.0", tk.END)
self.converted_tracks = []
self.track_combo['values'] = ["全部"]
self.track_var.set("全部")
self.copy_btn.config(state=tk.DISABLED)
self.info_label.config(text="💡 请导入MIDI文件查看转换结果")
self.count_label.config(text="")
self.update_status("已清空")
# ========== MIDI转换核心代码 ==========
def midi_to_jianpu_multi_track(self, midi_file_path):
"""
将MIDI文件转换为多条简谱(每个轨道独立)
支持升号 # 音符
"""
# 完整音符映射(包含升号 #)
note_map = {
# 中音区 (C4-B4)
60: '1', 61: '1#', 62: '2', 63: '2#', 64: '3',
65: '4', 66: '4#', 67: '5', 68: '5#', 69: '6',
70: '6#', 71: '7',
# 高八度 (C5-B5)
72: "1'", 73: "1#'", 74: "2'", 75: "2#'", 76: "3'",
77: "4'", 78: "4#'", 79: "5'", 80: "5#'", 81: "6'",
82: "6#'", 83: "7'",
# 高两个八度 (C6-B6)
84: "1''", 85: "1#''", 86: "2''", 87: "2#''", 88: "3''",
89: "4''", 90: "4#''", 91: "5''", 92: "5#''", 93: "6''",
94: "6#''", 95: "7''",
# 高三个八度 (C7-B7)
96: "1'''", 97: "1#'''", 98: "2'''", 99: "2#'''", 100: "3'''",
101: "4'''", 102: "4#'''", 103: "5'''", 104: "5#'''", 105: "6'''",
106: "6#'''", 107: "7'''",
# 低八度 (C3-B3)
48: '1.', 49: '1#.', 50: '2.', 51: '2#.', 52: '3.',
53: '4.', 54: '4#.', 55: '5.', 56: '5#.', 57: '6.',
58: '6#.', 59: '7.',
# 低两个八度 (C2-B2)
36: '1..', 37: '1#..', 38: '2..', 39: '2#..', 40: '3..',
41: '4..', 42: '4#..', 43: '5..', 44: '5#..', 45: '6..',
46: '6#..', 47: '7..',
# 低三个八度 (C1-B1)
24: '1…', 25: '1#…', 26: '2…', 27: '2#…', 28: '3…',
29: '4…', 30: '4#…', 31: '5…', 32: '5#…', 33: '6…',
34: '6#…', 35: '7…',
}
try:
mid = mido.MidiFile(midi_file_path)
ticks_per_beat = mid.ticks_per_beat
tracks_result = []
global_tempo = 500000
for track_idx, track in enumerate(mid.tracks):
active_notes = {}
note_events = []
current_tempo = global_tempo
track_name = f"音轨{track_idx + 1}"
abs_time = 0
for msg in track:
abs_time += msg.time
if msg.is_meta:
if msg.type == 'set_tempo':
current_tempo = msg.tempo
global_tempo = current_tempo
elif msg.type == 'track_name':
track_name = msg.name
continue
if msg.type == 'note_on' and msg.velocity > 0:
active_notes[msg.note] = abs_time
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
if msg.note in active_notes:
duration = abs_time – active_notes[msg.note]
note_events.append((active_notes[msg.note], duration, msg.note))
del active_notes[msg.note]
if not note_events:
continue
note_events.sort(key=lambda x: x[0])
quarter_ticks = ticks_per_beat
score_parts = []
for start_time, duration, note in note_events:
note_str = note_map.get(note, '')
if not note_str:
continue
beat_ratio = duration / quarter_ticks
duration_symbol = self._get_duration_symbol(beat_ratio)
score_parts.append(note_str)
if duration_symbol:
score_parts.append(duration_symbol)
bpm = int(mido.tempo2bpm(current_tempo))
jianpu_text = ''.join(score_parts)
# 限制长度
if len(jianpu_text) > 5000:
jianpu_text = jianpu_text[:5000]
tracks_result.append({
'name': track_name,
'bpm': bpm,
'jianpu': jianpu_text,
'track_index': track_idx
})
return tracks_result if tracks_result else None
except Exception as e:
print(f"MIDI导入错误: {e}")
return None
def _get_duration_symbol(self, beat_ratio):
"""根据拍数比例返回时值符号"""
beats = round(beat_ratio * 4) / 4
if beats >= 4.0:
return '—-'
elif beats >= 3.5:
return '—'
elif beats >= 3.0:
return '—'
elif beats >= 2.5:
return '–'
elif beats >= 2.0:
return '–'
elif beats >= 1.75:
return '-'
elif beats >= 1.5:
return '-'
elif beats >= 1.25:
return '-'
elif beats >= 1.0:
return ''
else:
return ''
def main():
root = tk.Tk()
# 设置应用图标(如果有的话)
try:
root.iconbitmap('icon.ico')
except:
pass
app = MidiToJianpuMobile(root)
root.mainloop()
if __name__ == "__main__":
main()
网硕互联帮助中心





评论前必须登录!
注册