diff --git a/README.md b/README.md index 3c63b26..d6bae33 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,10 @@ ## 致谢🙏 -- 感谢由 [Fuckcraft](https://github.com/fuckcraft) “鸣凤鸽子”等 带来的我的世界websocket服务器功能 - 感谢 昀梦\ 找出指令生成错误bug并指正 -- 感谢由 Charlie_Ping “查理平” 带来的bdx转换功能 -- 感谢由 CMA_2401PT 带来的 BDXWorkShop 供本程序对于bdx操作的指导 -- 感谢由 Miracle Plume “神羽” \带来的羽音缭绕基岩版音色资源包 -- 感谢由 Dislink Sforza \带来的midi转换算法,我们将其加入了我们众多算法之一 -- 感谢 Arthur Morgan 对本程序的排错提出了最大的支持 +- 感谢由 Charlie_Ping “查理平” 带来的bdx文件转换参考 +- 感谢由 CMA_2401PT 为我们的软件开发进行指导 +- 感谢由 Dislink Sforza \带来的midi音色解析以及转换指令的算法,我们将其加入了我们众多算法之一 - 感谢广大群友为此程序提供的测试等支持 - 若您对我们有所贡献但您的名字没有显示在此列表中,请联系我! diff --git a/example_convert_bdx.py b/example_convert_bdx.py new file mode 100644 index 0000000..c4ade89 --- /dev/null +++ b/example_convert_bdx.py @@ -0,0 +1,9 @@ + + + +# THIS PROGRAM IS ONLY A TEST EXAMPLE + + +from main import * + +midiConvert(input('请输入midi文件路径:'), input('请输入输出路径:')).toBDXfile(1,input('请输入作者:'),int(input('请输入指令结构最大生成高度:')),input('请输入计分板名称:'),float(input('请输入音量(0-1]:')),float(input('请输入速度倍率:'))) \ No newline at end of file diff --git a/example_convert.py b/example_convert_mcpack.py similarity index 100% rename from example_convert.py rename to example_convert_mcpack.py diff --git a/main.py b/main.py index 9a6783d..de5b608 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,22 @@ # -*- coding:utf-8 -*- -import mido -import os -import json -import uuid -import zipfile -import shutil -import zipfile +''' + Copyright © 2022 Team-Ryoun 金羿("Eilles Wan") & 诸葛亮与八卦阵("bgArray") + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +''' + +import os def makeZip(sourceDir, outFilename, compression=8, exceptFile=None): @@ -18,6 +27,8 @@ def makeZip(sourceDir, outFilename, compression=8, exceptFile=None): BZIP2 = 12\n LZMA = 14\n """ + import zipfile + zipf = zipfile.ZipFile(outFilename, 'w', compression) pre_len = len(os.path.dirname(sourceDir)) for parent, dirnames, filenames in os.walk(sourceDir): @@ -30,10 +41,12 @@ def makeZip(sourceDir, outFilename, compression=8, exceptFile=None): zipf.close() - class midiConvert: def __init__(self, midiFile: str, outputPath: str): '''简单的midi转换类,将midi文件转换为我的世界结构或者包''' + + import mido + self.midiFile = midiFile '''midi文件路径''' self.midi = mido.MidiFile(self.midiFile) @@ -44,10 +57,7 @@ class midiConvert: self.midFileName = os.path.splitext(os.path.basename(self.midiFile))[0] '''文件名,不含路径且不含后缀''' - - - - def __Inst2SoundID(self,instrumentID, default='note.harp'): + def __Inst2SoundID(self, instrumentID, default='note.harp'): '''返回midi的乐器ID对应的我的世界乐器名 :param instrumentID: midi的乐器ID :param default: 如果instrumentID不在范围内,返回的默认我的世界乐器名称 @@ -84,8 +94,9 @@ class midiConvert: return 'note.xylophone' return default - - def _toCmdList_m1(self,scoreboardname : str = 'mscplay',volume:float = 1.0, speed:float = 1.0) -> list: + def _toCmdList_m1( + self, scoreboardname: str = 'mscplay', volume: float = 1.0, speed: float = 1.0 + ) -> list: '''使用Dislink Sforza的转换算法,将midi转换为我的世界命令列表 :param scoreboardname: 我的世界的计分板名称 :param volume: 音量,注意:这里的音量范围为(0,1],如果超出将被处理为正确值,其原理为在距离玩家 (1 / volume -1) 的地方播放音频 @@ -96,31 +107,53 @@ class midiConvert: volume = 1 if volume <= 0: volume = 0.001 - + + commands = 0 + for i, track in enumerate(self.midi.tracks): - ticks=0 - commands=0 - instrumentID=0 + ticks = 0 + instrumentID = 0 singleTrack = [] for msg in track: if msg.is_meta: if msg.type == 'set_tempo': - tempo=msg.tempo + tempo = msg.tempo if msg.type == 'program_change': - instrumentID=msg.program + instrumentID = msg.program else: - ticks+=msg.time + ticks += msg.time if msg.type == 'note_on' and msg.velocity != 0: - singleTrack.append('execute @a[scores={'+scoreboardname+'='+str(round((ticks*tempo)/((self.midi.ticks_per_beat*float(speed))*50000)))+'}'+f'] ~~~ playsound {self.__Inst2SoundID(instrumentID)} @s ~~{1/volume-1}~ {msg.velocity*(0.7 if msg.channel == 0 else 0.9)} {2**((msg.note-66)/12)}') - commands+=1 + singleTrack.append( + 'execute @a[scores={' + + scoreboardname + + '=' + + str( + round( + (ticks * tempo) + / ( + (self.midi.ticks_per_beat * float(speed)) + * 50000 + ) + ) + ) + + '}' + + f'] ~~~ playsound {self.__Inst2SoundID(instrumentID)} @s ~~{1/volume-1}~ {msg.velocity*(0.7 if msg.channel == 0 else 0.9)} {2**((msg.note-66)/12)}' + ) + commands += 1 tracks.append(singleTrack) - - return tracks - - def tomcpack(self,method:int = 1,scoreboardname : str = 'mscplay',volume:float = 1.0, speed:float = 1.0) -> bool: + + return tracks, commands + + def tomcpack( + self, + method: int = 1, + scoreboardname: str = 'mscplay', + volume: float = 1.0, + speed: float = 1.0, + ) -> bool: '''使用method指定的转换算法,将midi转换为我的世界mcpack格式的包 :param method: 转换算法 :param scoreboardname: 我的世界的计分板名称 @@ -128,10 +161,14 @@ class midiConvert: :param speed: 速度,注意:这里的速度指的是播放倍率,其原理为在播放音频的时候,每个音符的播放时间除以 speed :return 成功与否,成功返回(True,True),失败返回(False,str失败原因)''' if method == 1: - cmdlist = self._toCmdList_m1(scoreboardname,volume,speed) + cmdlist, _a = self._toCmdList_m1(scoreboardname, volume, speed) else: - return (False,f'无法找到算法ID{method}对应的转换算法') - + return (False, f'无法找到算法ID{method}对应的转换算法') + del _a + + import json + import uuid + import shutil # 当文件f夹{self.outputPath}/temp/functions存在时清空其下所有项目,若其不存在则创建 if os.path.exists(f'{self.outputPath}/temp/functions/'): @@ -141,29 +178,224 @@ class midiConvert: # 写入manifest.json if not os.path.exists(f'{self.outputPath}/temp/manifest.json'): with open(f"{self.outputPath}/temp/manifest.json", "w") as f: - f.write("{\n \"format_version\": 1,\n \"header\": {\n \"description\": \"" + self.midFileName + " Pack : behavior pack\",\n \"version\": [ 0, 0, 1 ],\n \"name\": \"" + self.midFileName + "Pack\",\n \"uuid\": \"" + str(uuid.uuid4()) + "\"\n },\n \"modules\": [\n {\n \"description\": \"" + f"the Player of the Music {self.midFileName}" + "\",\n \"type\": \"data\",\n \"version\": [ 0, 0, 1 ],\n \"uuid\": \"" + str(uuid.uuid4()) + "\"\n }\n ]\n}") + f.write( + "{\n \"format_version\": 1,\n \"header\": {\n \"description\": \"" + + self.midFileName + + " Pack : behavior pack\",\n \"version\": [ 0, 0, 1 ],\n \"name\": \"" + + self.midFileName + + "Pack\",\n \"uuid\": \"" + + str(uuid.uuid4()) + + "\"\n },\n \"modules\": [\n {\n \"description\": \"" + + f"the Player of the Music {self.midFileName}" + + "\",\n \"type\": \"data\",\n \"version\": [ 0, 0, 1 ],\n \"uuid\": \"" + + str(uuid.uuid4()) + + "\"\n }\n ]\n}" + ) else: with open(f'{self.outputPath}/temp/manifest.json', 'r') as manifest: - data=json.loads(manifest.read()) - data['header']['description']=f"the Player of the Music {self.midFileName}" - data['header']['name']=self.midFileName - data['header']['uuid']=str(uuid.uuid4()) - data['modules'][0]['description']='None' - data['modules'][0]['uuid']=str(uuid.uuid4()) - manifest.close() - open(f'{self.outputPath}/temp/manifest.json','w').write(json.dumps(data)) + data = json.loads(manifest.read()) + data['header'][ + 'description' + ] = f"the Player of the Music {self.midFileName}" + data['header']['name'] = self.midFileName + data['header']['uuid'] = str(uuid.uuid4()) + data['modules'][0]['description'] = 'None' + data['modules'][0]['uuid'] = str(uuid.uuid4()) + manifest.close() + open(f'{self.outputPath}/temp/manifest.json', 'w').write(json.dumps(data)) # 将命令列表写入文件 - indexfile = open(f'{self.outputPath}/temp/functions/index.mcfunction', 'w', encoding='utf-8') + indexfile = open( + f'{self.outputPath}/temp/functions/index.mcfunction', 'w', encoding='utf-8' + ) for track in cmdlist: - indexfile.write('function mscplay/track'+str(cmdlist.index(track)+1)+'\n') - with open(f'{self.outputPath}/temp/functions/mscplay/track{cmdlist.index(track)+1}.mcfunction','w',encoding='utf-8') as f: + indexfile.write( + 'function mscplay/track' + str(cmdlist.index(track) + 1) + '\n' + ) + with open( + f'{self.outputPath}/temp/functions/mscplay/track{cmdlist.index(track)+1}.mcfunction', + 'w', + encoding='utf-8', + ) as f: f.write('\n'.join(track)) - indexfile.write('scoreboard players add @a[scores={'+scoreboardname+'=1..}] '+scoreboardname+' 1\n') + indexfile.write( + 'scoreboard players add @a[scores={' + + scoreboardname + + '=1..}] ' + + scoreboardname + + ' 1\n' + ) indexfile.close() - makeZip(f'{self.outputPath}/temp/',self.outputPath+f'/{self.midFileName}.mcpack') + makeZip( + f'{self.outputPath}/temp/', self.outputPath + f'/{self.midFileName}.mcpack' + ) shutil.rmtree(f'{self.outputPath}/temp/') - \ No newline at end of file + def toBDXfile( + self, + method: int, + author: str, + maxheight: int, + scoreboardname: str = 'mscplay', + volume: float = 1.0, + speed: float = 1.0, + ) -> bool: + '''使用method指定的转换算法,将midi转换为BDX结构文件 + :param method: 转换算法 + :param scoreboardname: 我的世界的计分板名称 + :param volume: 音量,注意:这里的音量范围为(0,1],如果超出将被处理为正确值,其原理为在距离玩家 (1 / volume -1) 的地方播放音频 + :param speed: 速度,注意:这里的速度指的是播放倍率,其原理为在播放音频的时候,每个音符的播放时间除以 speed + :return 成功与否,成功返回(True,未经过压缩的源),失败返回(False,str失败原因)''' + + import brotli + + if method == 1: + cmdlist, totalcount = self._toCmdList_m1(scoreboardname, volume, speed) + else: + return (False, f'无法找到算法ID{method}对应的转换算法') + + if not os.path.exists(self.outputPath): + os.makedirs(self.outputPath) + + with open(f"{self.outputPath}/{self.midFileName}.bdx", "w+") as f: + f.write("BD@") + + _bytes = ( + b"BDX\x00" + + author.encode("utf-8") + + b" & Musicreater\x00\x01command_block\x00" + ) + + key = { + "x": (b"\x0f", b"\x0e"), + "y": (b"\x11", b"\x10"), + "z": (b"\x13", b"\x12"), + } + '''key存储了方块移动指令的数据,其中可以用key[x|y|z][0|1]来表示xyz的减或增''' + x = 'x' + y = 'y' + z = 'z' + + def __formCMDblk( + command: str, + particularValue: int, + impluse: int = 0, + condition: bool = False, + needRedstone: bool = True, + tickDelay: int = 0, + customName: str = '', + executeOnFirstTick: bool = False, + trackOutput: bool = True, + ): + """ + 使用指定项目返回指定的指令方块放置指令项 + :param command: `str` + 指令 + :param particularValue: + 方块特殊值,即朝向 + :0 下 无条件 + :1 上 无条件 + :2 z轴负方向 无条件 + :3 z轴正方向 无条件 + :4 x轴负方向 无条件 + :5 x轴正方向 无条件 + :6 下 无条件 + :7 下 无条件 + + :8 下 有条件 + :9 上 有条件 + :10 z轴负方向 有条件 + :11 z轴正方向 有条件 + :12 x轴负方向 有条件 + :13 x轴正方向 有条件 + :14 下 有条件 + :14 下 有条件 + 注意!此处特殊值中的条件会被下面condition参数覆写 + :param impluse: `int 0|1|2` + 方块类型 + 0脉冲 1循环 2连锁 + :param condition: `bool` + 是否有条件 + :param needRedstone: `bool` + 是否需要红石 + :param tickDelay: `int` + 执行延时 + :param customName: `str` + 悬浮字 + :param lastOutput: `str` + 上次输出字符串,注意此处需要留空 + :param executeOnFirstTick: `bool` + 执行第一个已选项(循环指令方块是否激活后立即执行,若为False,则从激活时起延迟后第一次执行) + :param trackOutput: `bool` + 是否输出 + + :return:str + """ + block = b"\x24" + particularValue.to_bytes(2, byteorder="big", signed=False) + + for i in [ + impluse.to_bytes(4, byteorder="big", signed=False), + bytes(command, encoding="utf-8") + b"\x00", + bytes(customName, encoding="utf-8") + b"\x00", + bytes('', encoding="utf-8") + b"\x00", + tickDelay.to_bytes(4, byteorder="big", signed=True), + executeOnFirstTick.to_bytes(1, byteorder="big"), + trackOutput.to_bytes(1, byteorder="big"), + condition.to_bytes(1, byteorder="big"), + needRedstone.to_bytes(1, byteorder="big"), + ]: + block += i + return block + + def __fillSquareSideLength(self, total: int, maxHeight: int): + '''给定总方块数量和最大高度,返回所构成的图形外切正方形的边长 + :param total: 总方块数量 + :param maxHeight: 最大高度 + :return: 外切正方形的边长 int''' + import math + + math.ceil(math.sqrt(total / maxHeight)) + + _sideLength = __fillSquareSideLength(totalcount, maxheight) + + yforward = True + zforward = True + + nowy = 0 + nowz = 0 + + for track in cmdlist: + for cmd in track: + _bytes += __formCMDblk( + cmd, + (1 if yforward else 0) + if (nowy != 0) and (nowy != maxheight) + else (3 if zforward else 2), + impluse=2, + condition=False, + needRedstone=False, + tickDelay=0, + customName='', + executeOnFirstTick=False, + trackOutput=True, + ) + nowy += 1 if yforward else -1 + _bytes += key[y][int(yforward)] + if ((nowy > maxheight) and (yforward)) or ( + (nowy < 0) and (not yforward) + ): + yforward = not yforward + nowz += 1 if zforward else -1 + _bytes += key[z][int(zforward)] + if ((nowz > _sideLength) and (zforward)) or ( + (nowz < 0) and (not zforward) + ): + zforward = not zforward + _bytes += key[x][1] + + with open(f"{self.outputPath}/{self.midFileName}.bdx", "ab+") as f: + f.write(brotli.compress(_bytes + b'XE')) + + return (True, _bytes)