Skip to content

Instantly share code, notes, and snippets.

@DylanJones
Last active September 8, 2020 01:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DylanJones/eeb440c9df9286b406085d4559e0b1f2 to your computer and use it in GitHub Desktop.
Save DylanJones/eeb440c9df9286b406085d4559e0b1f2 to your computer and use it in GitHub Desktop.
MIDI to minecraft shulker boxes converter
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