Last active
September 8, 2020 01:23
-
-
Save DylanJones/eeb440c9df9286b406085d4559e0b1f2 to your computer and use it in GitHub Desktop.
MIDI to minecraft shulker boxes converter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import mido | |
import math | |
import os | |
import argparse | |
import time | |
import json | |
def format_note(note): | |
""" | |
Given a numeric note number, format it as a Letter(#) Octave string. | |
""" | |
# C 0 is midi number 12 | |
base = ['C', 'C#', 'D', 'E♭', 'E', 'F', 'F#', 'G', 'G#', 'A', 'B♭', 'B'] | |
octave = (note) // len(base) - 1 | |
name = base[(note) % len(base)] | |
return f'{name} {octave}' | |
def to_box_data(bits, name='BitBox', color='red'): | |
""" | |
Given a list of up to 27 bits, create a dict representing a shulker box item that | |
contains those bits with potions/carrot on a sticks. | |
""" | |
if len(bits) > 27: | |
raise ValueError("Too many bits to fit in this shulker box") | |
box = {'id': f'minecraft:{color}_shulker_box', 'Count': 1, 'tag': {'display': {'Name': f'{{"text":"{name}"}}'}, 'BlockEntityTag': {'Items': []}}} | |
items = box['tag']['BlockEntityTag']['Items'] | |
zero = {'Slot': 0, 'id': 'minecraft:carrot_on_a_stick', 'Count': 1} | |
one = {'Slot': 0, 'id': 'minecraft:potion', 'Count': 1} | |
for i,bit in enumerate(bits): | |
if bit: | |
items.append(one.copy()) | |
else: | |
items.append(zero.copy()) | |
items[i]['Slot'] = i | |
return box | |
def split_shulkers(encoded): | |
""" | |
Given a very long list of 4-bit numbers, encode them as 4 different lists | |
of shulker boxes. Each number is split up across 4 shulker boxes | |
in RAID 0, and those shulker boxes are distributed across the 4 lists. | |
""" | |
bit_lists = [[] for _ in range(4)] | |
# First, split into chunks of 27 numbers | |
for box_num,note_num in enumerate(range(0, len(encoded), 27)): | |
notes = encoded[note_num:note_num+27] | |
# Split into four different boxes | |
for idx in range(4): | |
bits = [x >> idx & 1 for x in notes] | |
box = to_box_data(bits, name=f'Bit {idx} Box {box_num}') | |
bit_lists[idx].append(box) | |
return bit_lists | |
def to_minecraft_data(encoded, outfile): | |
""" | |
Given the list of 4-bit numbers, convert them to 4 /give commands that each | |
give one of the song's bits in shulker form. | |
""" | |
bits = split_shulkers(encoded) | |
base_chest = {'id': 'minecraft:chest', 'Count': 1, 'tag': {'BlockEntityTag': {'Items': []}}} | |
with open(outfile, 'w') as f: | |
for i,lst in enumerate(bits): | |
chest = base_chest.copy() | |
chest['tag']['display'] = {'Name': f'{{"text": "Bit {i}"}}'} | |
if len(lst) > 27: | |
print(f"help this is bork there are {len(lst)} bit box") | |
#raise RuntimeError("too many bit") | |
chest['tag']['BlockEntityTag']['Items'] = lst | |
for j,box in enumerate(lst): | |
box['Slot'] = j | |
stringified = json.dumps(chest['tag']) | |
f.write('give @s minecraft:chest') | |
f.write(stringified) | |
f.write('\n') | |
# print(f'GIVE COMMAND FOR BIT {i} (COPY AND PASTE):\n\n\n/give @p minecraft:chest{stringified}\n\n') | |
def main(): | |
parser = argparse.ArgumentParser() | |
parser.add_argument('infile', help='The midi file to parse') | |
parser.add_argument('outfile', help='The .mcfunction file to output to') | |
parser.add_argument('--play', action='store_const', dest='play', const=True, | |
default=False, help='Play the cut-down MIDI') | |
parser.add_argument('--track', type=int, default=0, help='Track number to extract') | |
parser.add_argument('--min_timestep', type=int, default=None, help='Minimum timestep between notes, used for parsing. Default is to autodetect.') | |
parser.add_argument('--offset', type=int, default=0, help='Number of semitones to offset the note blocks by.') | |
args = parser.parse_args() | |
# Start parsing! | |
f = mido.MidiFile(args.infile) | |
port_name = mido.get_output_names()[0] | |
print(f'Opening output port {port_name}') | |
port = mido.open_output(port_name) | |
track = f.tracks[args.track] | |
done_indicies = set() | |
good_messages = [] | |
if args.min_timestep is None: | |
min_timestep = min(m.time for m in track if m.type in ['note_on', 'note_off'] and m.time > 1) | |
else: | |
min_timestep = args.min_timestep | |
print(f"Detected a minimum note interval of {min_timestep} ticks/note") | |
#for i,ms in enumerate(track): | |
# if ms.type in ['note_on', 'note_off']: | |
# ms.velocity=127 | |
# else: | |
# continue | |
# print(f'{i=} {ms=}') | |
# time.sleep(ms.time/1000) | |
# if not ms.is_meta: | |
# port.send(ms) | |
# We can only play one pitch at a time, so we need to rearrange | |
for i,msg in enumerate(track): | |
if msg.type not in ['note_on', 'note_off']: | |
continue | |
if i in done_indicies: | |
continue | |
if msg.type == 'note_on': | |
msg.velocity = 127 | |
# print(f'{msg=}') | |
# find the corresponding note_off | |
for j in range(i, min(i+150, len(track))): | |
if (msg2 := track[j]).type == 'note_off' and msg2.note == msg.note: | |
done_indicies.add(j) | |
# print(f'{msg2.type=} {msg2=}') | |
break | |
else: | |
print("BAD") | |
msg2.time = msg.time + msg2.time | |
msg.time = 0 | |
good_messages.append(msg) | |
good_messages.append(msg2) | |
else: | |
print(f"Something has gone wrong at {msg=} {i=}") | |
# Note data before it's been transposed down to the correct key | |
note_values = [] | |
for i,msg in enumerate(good_messages): | |
if msg.type == 'note_on': | |
on, off = msg, good_messages[i+1] | |
if (note_time := round((on.time + off.time) / min_timestep)) < 1: | |
print(f'WARNING: detected bad interval between {on=} and {off=} at index {i=}, cutting from song!') | |
continue | |
note_values.append(msg.note) | |
note_values.extend(0 for _ in range(note_time - 1)) | |
key = sorted(set(note_values)) | |
encode_key = {note: i for i,note in enumerate(key)} | |
compressed = [encode_key[x] for x in note_values] | |
print(f'There are {len(key)} unique notes in this piece.') | |
if len(key) <= 16: | |
print("This can fit in the music machine! Here are the required tunings:") | |
for i,note in enumerate(key): | |
if note == 0: | |
print('0 - nothing (rest)') | |
else: | |
note += args.offset | |
print(f'{i} - {format_note(note)} (note block {note - 54})') | |
else: | |
print("Error: too many unique notes in this piece! Please try reducing the count or splitting into different tracks/files.") | |
to_minecraft_data(compressed, args.outfile) | |
if args.play: | |
for val in note_values: | |
if val == 0: | |
time.sleep(0.1) | |
continue | |
ms = mido.Message('note_on', note=val, velocity=127, time=0) | |
port.send(ms) | |
time.sleep(0.15) | |
print(val) | |
ms = mido.Message('note_off', note=val, time=0) | |
port.send(ms) | |
previous_size = math.ceil(len(compressed) / 2) | |
final_size = os.path.getsize(args.outfile) | |
print(f'Before minecraftifying, the song took up {previous_size} bytes. Now, it takes up {final_size} bytes, only {round(final_size / previous_size * 100, 2)}% the space of the original!') | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment