├── .gitignore ├── LICENSE ├── README.md ├── example.mid ├── midi2gcode.config ├── midi2gcode.exe └── midi2gcode.py /.gitignore: -------------------------------------------------------------------------------- 1 | /input.mid -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MechRedPanda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `midi2gcode` 是一个把 MIDI 文件转换成 3D 打印机能够读取的 Gcode 开源软件。 2 | 3 | 使用效果展示: 4 | 5 | - 《最伟大的作品》https://www.bilibili.com/video/BV1vd4y1M7er/ 6 | - 《歌剧魅影》https://youtu.be/L_MdsEhdfWM 7 | 8 | 它的主要特点是 9 | 10 | - 支持同时使用打印机的 X 和 Y 轴 11 | - 支持自定义打印机参数 12 | - 支持 CoreXY 结构 13 | - 支持多音轨的 MIDI 文件 14 | - 全部使用 Python 实现,简单易懂 15 | 16 | # 使用说明 17 | 18 | 1. 下载项目文件夹并解压缩 19 | 2. 根据自己的打印机参数,修改配置文件 `midi2gcode.config`。 20 | 配置文件中默认设置为 **Voron 0.1** 3D 打印机。如果你的打印机参数设置与默认设置不同,那么生成的 Gcode 就有可能不能正常工作,甚至可能损坏打印机。所以请务必检查参数设置。 21 | 3. 把需要生成的 MIDI 文件复制到文件夹下,修改配置文件 `midi2gcode.config`中的`filename`的值,或者把 MIDI 文件重命名成默认值 `example.mid` 22 | 4. 双击运行 `midi2gcode.exe` 23 | 5. MIDI 文件就会被转换成多个 `.gcode`的文件,分别对应 MIDI 文件从的不同音轨,从高频率到低频率排列。通常高频率的音轨表现更好。 24 | -------------------------------------------------------------------------------- /example.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MechRedPanda/midi2gcode-main/ec2dbeb8aa25ad6149ef1451b0441d0d6f754d3f/example.mid -------------------------------------------------------------------------------- /midi2gcode.config: -------------------------------------------------------------------------------- 1 | [PRINTER] 2 | # Settings for stock Voron 0.1 3 | is_corexy = true 4 | # Speeds are in mm/min 5 | max_speed = 2000 6 | travel_speed = 1000 7 | # Z height when playing music. 8 | z_height = 10 9 | x_min = 10 10 | x_max = 110 11 | a_steps_per_mm = 160 12 | y_min = 10 13 | y_max = 110 14 | b_steps_per_mm = 160 15 | 16 | [MIDI] 17 | filename = example.mid -------------------------------------------------------------------------------- /midi2gcode.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MechRedPanda/midi2gcode-main/ec2dbeb8aa25ad6149ef1451b0441d0d6f754d3f/midi2gcode.exe -------------------------------------------------------------------------------- /midi2gcode.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import math 3 | import collections 4 | import mido 5 | from mido import MidiFile 6 | 7 | # Configs 8 | CONFIG = configparser.ConfigParser() 9 | CONFIG.read('midi2gcode.config') 10 | NUM_CHANNELS = 2 11 | 12 | MIDI_FILE_NAME = CONFIG["MIDI"]["filename"] 13 | 14 | class Printer: 15 | def __init__(self, config: configparser.ConfigParser): 16 | self.x_min = float(config['PRINTER']['x_min']) 17 | self.x_max = float(config['PRINTER']['x_max']) 18 | self.y_min = float(config['PRINTER']['y_min']) 19 | self.y_max = float(config['PRINTER']['y_max']) 20 | self.z_height = float(config['PRINTER']['z_height']) 21 | self.a_steps_per_mm = float(config['PRINTER']['a_steps_per_mm']) 22 | self.b_steps_per_mm = float(config['PRINTER']['b_steps_per_mm']) 23 | self.is_corexy = config['PRINTER'].getboolean('is_corexy') 24 | self.max_speed = float(config['PRINTER']['max_speed']) 25 | self.travel_speed = float(config['PRINTER']['travel_speed']) 26 | 27 | # Current printer status 28 | self.current_pos = [self.x_min, self.y_min] 29 | self.current_dir = [1, 1] 30 | 31 | def init_gcode(self): 32 | gcode = [] 33 | # Home X, Y and Z 34 | gcode.append('G28 X Y Z') 35 | gcode.append(f'G1 X{self.x_min} Y{self.y_min} Z{self.z_height} F{self.travel_speed}') 36 | gcode.append('G4 P1000') 37 | return gcode 38 | 39 | def move(self, x, y, feed_rate): 40 | if x >= self.x_max or x <= self.x_min: 41 | raise ValueError(f'x:{x} y:{y}') 42 | if y >= self.y_max or y <= self.y_min: 43 | raise ValueError(f'x:{x} y:{y}') 44 | 45 | return f'G1 X{x} Y{y} F{feed_rate}' 46 | 47 | @classmethod 48 | def freq2feedrate(cls, freq, steps_per_mm): 49 | return 60*freq/steps_per_mm 50 | 51 | @classmethod 52 | def calculate_distance(cls, feedrate, duration): 53 | return feedrate/60*duration 54 | 55 | def freq2gcode(self, freqs, duration_s): 56 | if len(freqs) > 2: 57 | raise ValueError() 58 | if len(freqs) == 0: 59 | return f'G4 P{duration_s*1000}' 60 | if len(freqs) == 1: 61 | feed_rate_a = self.freq2feedrate(freqs[0], self.a_steps_per_mm) 62 | feed_rate_b = 0 63 | else: 64 | feed_rate_a = self.freq2feedrate(freqs[0], self.a_steps_per_mm) 65 | feed_rate_b = self.freq2feedrate(freqs[1], self.b_steps_per_mm) 66 | delta_a = self.calculate_distance(feed_rate_a, duration_s) 67 | delta_b = self.calculate_distance(feed_rate_b, duration_s) 68 | if self.is_corexy: 69 | delta_x = (delta_a + delta_b)/2 * self.current_dir[0] 70 | delta_y = (delta_b - delta_a)/2 * self.current_dir[1] 71 | else: 72 | delta_x = delta_a * self.current_dir[0] 73 | delta_y = delta_b * self.current_dir[1] 74 | new_x = self.current_pos[0] + delta_x 75 | if new_x >= self.x_max or new_x <= self.x_min: 76 | new_x = self.current_pos[0] - delta_x 77 | self.current_dir[0] = - self.current_dir[0] 78 | new_y = self.current_pos[1] + delta_y 79 | if new_y >= self.y_max or new_y <= self.y_min: 80 | new_y = self.current_pos[1] - delta_y 81 | self.current_dir[1] = - self.current_dir[1] 82 | feed_rate = math.sqrt(delta_x*delta_x + delta_y*delta_y)/duration_s*60 83 | self.current_pos = [new_x, new_y] 84 | try: 85 | moves = self.move(new_x, new_y, feed_rate) 86 | return moves 87 | except: 88 | raise ValueError(f'delta_x {delta_x}') 89 | 90 | # Load Midi Files 91 | mid = MidiFile(MIDI_FILE_NAME) 92 | merged = mido.merge_tracks(mid.tracks) 93 | 94 | # Read Tempo from the file. 95 | # If it is missing then load the default value 96 | tempos = [] 97 | for msg in mid.tracks[0]: 98 | if msg.type == 'set_tempo': 99 | tempos.append((msg.time, msg.tempo)) 100 | if len(tempos) == 0: 101 | tempos = [(0, 480000)] 102 | tempos.sort(key=lambda y: y[0]) 103 | 104 | # Helper function to convert midi ticks to wall time. 105 | 106 | 107 | def ticks2second(tick, tempos): 108 | ticks_per_beat = mid.ticks_per_beat 109 | if len(tempos) == 1: 110 | return mido.tick2second(tick, ticks_per_beat, tempos[0][1]) 111 | time = 0.0 112 | for i in range(len(tempos)-1): 113 | tempo_t, tempo = tempos[i] 114 | tempo_t_next, tempo_next = tempos[i+1] 115 | if tick >= tempo_t and tick >= tempo_t_next: 116 | time += mido.tick2second(tempo_t_next - 117 | tempo_t, ticks_per_beat, tempo) 118 | continue 119 | if tick >= tempo_t and tick < tempo_t_next: 120 | time += mido.tick2second(tick-tempo_t, ticks_per_beat, tempo) 121 | return time 122 | tempo_t, tempo = tempos[-1] 123 | time += mido.tick2second(tick-tempo_t, ticks_per_beat, tempo) 124 | return time 125 | 126 | 127 | notes_change_dict = {} 128 | current_time = 0 129 | for msg in merged: 130 | current_time += msg.time 131 | if msg.type == 'note_on' or msg.type == 'note_off': 132 | if current_time in notes_change_dict: 133 | notes_change_dict[current_time].append(msg) 134 | else: 135 | notes_change_dict[current_time] = [msg] 136 | 137 | notes_change_by_timestamp = collections.OrderedDict( 138 | sorted(notes_change_dict.items())) 139 | 140 | current_notes = dict() 141 | notes_by_timestamp = dict() 142 | max_num_notes = 0 143 | for time, msgs in notes_change_by_timestamp.items(): 144 | notes_counter_this_time = dict() 145 | for msg in msgs: 146 | if msg.type == 'note_on' and msg.velocity > 0: 147 | if msg.note not in notes_counter_this_time: 148 | notes_counter_this_time[msg.note] = 1 149 | else: 150 | notes_counter_this_time[msg.note] += 1 151 | elif msg.type == 'note_off' or msg.velocity == 0: 152 | if msg.note not in notes_counter_this_time: 153 | notes_counter_this_time[msg.note] = -1 154 | else: 155 | notes_counter_this_time[msg.note] -= 1 156 | for note, change in notes_counter_this_time.items(): 157 | if change > 0: 158 | if note not in current_notes: 159 | current_notes[note] = change 160 | elif change < 0: 161 | if note in current_notes: 162 | del current_notes[note] 163 | notes_by_timestamp[time] = set(current_notes.keys()) 164 | if len(current_notes.keys()) > max_num_notes: 165 | max_num_notes = len(current_notes.keys()) 166 | notes_by_timestamp = collections.OrderedDict( 167 | sorted(notes_by_timestamp.items())) 168 | 169 | timestamps = [] 170 | all_notes = [] 171 | for key, value in notes_by_timestamp.items(): 172 | timestamps.append(key) 173 | all_notes.append(value) 174 | start_end_timestamps = [] 175 | for i in range(len(timestamps)-1): 176 | start_end_timestamps.append(timestamps[i:i+2]) 177 | 178 | 179 | def note_to_freq(note: int, base_freq=440): 180 | return base_freq*math.pow(2.0, (note-69)/12.0) 181 | 182 | # Start to convert midi file to gcode 183 | num_runs = math.ceil(max_num_notes / NUM_CHANNELS) 184 | for r in range(num_runs): 185 | note_index = [r * NUM_CHANNELS, r * NUM_CHANNELS + 1] 186 | # Init a new printer for every run. 187 | voron = Printer(config=CONFIG) 188 | gcode_list = [] 189 | gcode_list.extend(voron.init_gcode()) 190 | for start_end, notes in zip(start_end_timestamps, all_notes): 191 | if len(notes) > 0: 192 | notes_sorted = list(notes) 193 | notes_sorted.sort(reverse=True) 194 | notes_to_play = [] 195 | for ni in note_index: 196 | if ni >= len(notes_sorted): 197 | continue 198 | else: 199 | notes_to_play.append(notes_sorted[ni]) 200 | freqs = [note_to_freq(n) for n in notes_to_play] 201 | duration = ticks2second( 202 | start_end[1], tempos) - ticks2second(start_end[0], tempos) 203 | while(duration > 5.0): 204 | gcode_list.append(voron.freq2gcode(freqs, 5.0)) 205 | duration -= 5.0 206 | gcode_list.append(voron.freq2gcode(freqs, duration)) 207 | 208 | with open(f'{MIDI_FILE_NAME[:-4]}_{r}.gcode', 'w') as fp: 209 | for item in gcode_list: 210 | # write each item on a new line 211 | fp.write("%s\n" % item) 212 | 213 | print("Convert midi file to gcode compeleted!") 214 | print("Use it at your own risk!") --------------------------------------------------------------------------------