diff --git a/.gitignore b/.gitignore index 29ed922..1fbd570 100644 --- a/.gitignore +++ b/.gitignore @@ -221,7 +221,7 @@ ClientBin/ *.publishsettings orleans.codegen.cs -# Including strong name files can present a security risk +# Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk @@ -317,7 +317,7 @@ __pycache__/ # OpenCover UI analysis results OpenCover/ -# Azure Stream Analytics local run output +# Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log @@ -326,29 +326,29 @@ ASALocalRun/ # NVidia Nsight GPU debugger configuration file *.nvuser -# MFractors (Xamarin productivity tool) working folder +# MFractors (Xamarin productivity tool) working folder .mfractor/ ## Ignore output/exported files and datapacks. *.nbs *.mid *.mp3 -add*/ -pack.mcmeta/ +*.mscx +*.mscz +datapacks/ +Downloaded song/ ## Ignore other files/folders. #*.ico *.spec *.bat *.pyo -version.* - -.vscode/ build/ dist/ +test/ main.build/ main.dist/ +.mscbackup/ -sounds/block.note.sax.ogg setup.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7761838 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,78 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File (Integrated Terminal)", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + }, + { + "name": "Python: Current File and Libs", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Python: Remote Attach", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + "name": "Python: Module", + "type": "python", + "request": "launch", + "module": "enter-your-module-name-here", + "console": "integratedTerminal" + }, + { + "name": "Python: Django", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/manage.py", + "console": "integratedTerminal", + "args": [ + "runserver", + "--noreload", + "--nothreading" + ], + "django": true + }, + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "app.py" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload" + ], + "jinja": true + }, + { + "name": "Python: Current File (External Terminal)", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "externalTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2a3f8d1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.pythonPath": "C:\\Users\\Admin\\AppData\\Local\\Programs\\Python\\Python38-32\\python.exe", + "python.jediEnabled": false, + "python.languageServer": "Microsoft", +} \ No newline at end of file diff --git a/README.md b/README.md index 155bed6..594bd94 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,10 @@ [![License](https://img.shields.io/github/license/IoeCmcomc/NBSTool "License")](https://opensource.org/licenses/MIT "License") A tool to work with .nbs (Note Block Studio) files. + +## Features +- Process multiple files simultaneously; +- Modify header information and change between versions; +- Arrange notes; +- Import .nbs files from MuseScore files; +- Export .nbs files to MIDI files. diff --git a/attr.py b/attr.py deleted file mode 100644 index 858805e..0000000 --- a/attr.py +++ /dev/null @@ -1,227 +0,0 @@ -# This file is a part of: -#‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ -# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ -# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ -# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ -# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ -#__________________________________________________________________________________ -# NBSTool is a tool to work with .nbs (Note Block Studio) files. -# Author: IoeCmcomc (https://github.com/IoeCmcomc) -# Programming language: Python -# License: MIT license -# Version: 0.7.0 -# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) -#‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ - - -import sys - -class Attr: - def __init__(self, value=None, parent=None): - self._value_ = value - self._parent_ = parent - - def __eq__(self, other): - return self._value_ == other - def __ne__(self, other): - return self._value_ != other - def __lt__(self, other): - return self._value_ < other - def __gt__(self, other): - return self._value_ > other - def __le__(self, other): - return self._value_ <= other - def __ge__(self, other): - return self._value_ >= other - - def __pos__(self): - return +self._value_ - def __neg__(self): - return -self._value_ - def __abs__(self): - return abs(self._value_) - def __invert__(self): - return ~self._value_ - def __round__(self, n): - return round(self._value_, n) - def __floor__(self): - return math.floor(self._value_) - def __ceil__(self): - return math.ceil(self._value_) - def __trunc__(self): - return math.trunc(self._value_) - - def __add__(self, other): - return self._value_ + other - def __sub__(self, other): - return self._value_ - other - def __mul__(self, other): - return self._value_ * other - def __floordiv__(self, other): - return self._value_ // other - def __div__(self, other): - return self._value_ / other - def __truediv__(self, other): - return self._value_ / other - def __mod__(self, other): - return self._value_ % other - def __divmod__(self, other): - return divmod(self._value_, other) - def __pow__(self, other): - return self._value_ ** other - def __lshift__(self, other): - return self._value_ << other - def __rshift__(self, other): - return self._value_ >> other - def __and__(self, other): - return self._value_ & other - def __or__(self, other): - return self._value_ | other - def __xor__(self, other): - return self._value_ ^ other - - def __radd__(self, other): - return other + self._value_ - def __rsub__(self, other): - return other - self._value_ - def __rmul__(self, other): - return other * self._value_ - def __rfloordiv__(self, other): - return other // self._value_ - def __rdiv__(self, other): - return other / self._value_ - def __rtruediv__(self, other): - return other / self._value_ - def __rmod__(self, other): - return other % self._value_ - def __rdivmod__(self, other): - return divmod(other, self._value_) - def __rpow__(self, other): - return other ** self._value_ - def __rlshift__(self, other): - return other << self._value_ - def __rrshift__(self, other): - return other >> self._value_ - def __rand__(self, other): - return other & self._value_ - def __ror__(self, other): - return other | self._value_ - def __rxor__(self, other): - return other ^ self._value_ - - def __iadd__(self, other): - return self._value_ + other - def __isub__(self, other): - return self._value_ - other - def __imul__(self, other): - return self._value_ * other - def __ifloordiv__(self, other): - return self._value_ // other - def __idiv__(self, other): - return self._value_ / other - def __itruediv__(self, other): - return self._value_ / other - def __imod__(self, other): - return self._value_ % other - def __idivmod__(self, other): - return divmod(self._value_, other) - def __ipow__(self, other): - return self._value_ ** other - def __ilshift__(self, other): - return self._value_ << other - def __irshift__(self, other): - return self._value_ >> other - def __iand__(self, other): - return self._value_ & other - def __ior__(self, other): - return self._value_ | other - def __ixor__(self, other): - return self._value_ ^ other - - def __int__(self): - return int(self._value_) - def __long__(self): - return long(self._value_) - def __float__(self): - return float(self._value_) - def __complex__(self): - return complex(self._value_) - def __oct__(self): - return oct(self._value_) - def __hex__(self): - return hex(self._value_) - def __index__(self): - return self._value_ - def __trunc__(self): - return math.trunc(self._value_) - def __coerce__(self, other): - return coerce(self._value_, other) - - def __str__(self): - return str(self._value_) - def __repr__(self): - #return repr(self._value_) - reprStr = "".format \ - (hex(id(self)), repr(self._value_.__class__.__name__)) - return reprStr - def __unicode__(self): - return unicode(self._value_) - def __format__(self, formatstr): - pattern = '{0:'+formatstr+'}' - return pattern.format(self._value_) - def __hash__(self): - return hash(self._value_) - def __nonzero__(self): - return bool(self._value_) - def __dir__(self): - return super().__dir__() - def __sizeof__(self): - return sys.getsizeof(self._value_) - - def __setattr__(self, name, value): - if name[:1] == '_': - if len(name) == 1: - setattr(self, '_value_', value) - else: - super().__setattr__(name, value) - else: - super().__setattr__(name, Attr(value, self)) - def __getattr__(self, name): - valueAttr = getattr(self._value_, name, None) - if 'method' in valueAttr.__class__.__name__ or 'function' in valueAttr.__class__.__name__: - return valueAttr - elif name == '_': - - return self._value_ - else: - setattr(self, name, None) - return getattr(self, name) - - def __len__(self): - return len(self._value_) - def __getitem__(self, key): - return self._value_[key] - def __setitem__(self, key, value): - self._value_[key] = value - def __delitem__(self, key): - del self._value_[key] - def __iter__(self): - return iter(self._value_) - def __reversed__(self): - return reversed(self._value_) - def __contains__(self, item): - return item in self._value_ - ''' - def __missing__(self, key): - return super().__missing__(key) - - def __call__(self, value=None, parent=None): - print('called') - return self._value_ - if len(sys.argv) > 1: - if self._value_ is not value: self._value_ = value - if self._parent_ is not parent: self._parent_ = parent''' \ No newline at end of file diff --git a/customwidgets/__init__.py b/customwidgets/__init__.py new file mode 100644 index 0000000..79261e5 --- /dev/null +++ b/customwidgets/__init__.py @@ -0,0 +1,40 @@ +# This file is a part of: +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# __________________________________________________________________________________ +# NBSTool is a tool to work with .nbs (Note Block Studio) files. +# Author: IoeCmcomc (https://github.com/IoeCmcomc) +# Programming language: Python +# License: MIT license +# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + + +print(f'{__name__}') + +if __name__ == '__init__': # Pygubu import call + from wrapmessage import WrapMessage +else: # Normal import call + from .wrapmessage import WrapMessage +from pygubu import BuilderObject, register_widget + +class WrapMessageBuilder(BuilderObject): + class_ = WrapMessage + + OPTIONS_STANDARD = ('anchor', 'background', 'borderwidth', 'cursor', 'font', + 'foreground', 'highlightbackground', 'highlightcolor', + 'highlightthickness', 'padx', 'pady', 'relief', 'takefocus', + 'text', 'textvariable') + OPTIONS_SPECIFIC = ('aspect', 'justify', 'width', 'padding') + properties = OPTIONS_STANDARD + OPTIONS_SPECIFIC + +register_widget('customwidgets.wrapmessage', WrapMessageBuilder, + 'WrapMessage', ('tk', 'Custom')) + diff --git a/customwidgets/wrapmessage.py b/customwidgets/wrapmessage.py new file mode 100644 index 0000000..3a545f0 --- /dev/null +++ b/customwidgets/wrapmessage.py @@ -0,0 +1,65 @@ +# This file is a part of: +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# __________________________________________________________________________________ +# NBSTool is a tool to work with .nbs (Note Block Studio) files. +# Author: IoeCmcomc (https://github.com/IoeCmcomc) +# Programming language: Python +# License: MIT license +# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + + +from __future__ import print_function + +import tkinter as tk +import tkinter.ttk as ttk + +from tkinter.messagebox import showinfo + +from tkinter import Message + +class WrapMessage(Message): + padding = 10 + + def __init__(self, master=None, **kwargs): + super().__init__(master, **kwargs) + self.bind("", self._adjustWidth) + + def configure(self, cnf={}, **kw): + key = 'padding' + if key in cnf: + self.padding = int(cnf[key]) + del cnf[key] + if key in kw: + self.padding = int(kw[key]) + del kw[key] + + super().configure(cnf, **kw) + + config = configure + + def cget(self, key): + option = 'padding' + if key == option: + return self.padding + + return super().cget(key) + + def _adjustWidth(self, event): + event.widget.configure(width=event.width-self.padding) + + +if __name__ == '__main__': + root = tk.Tk() + msg = WrapMessage(root) + msg.configure(padding=40, text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus gravida libero ac commodo molestie. Donec iaculis velit sem, consequat bibendum libero cursus ut. Nulla ullamcorper placerat libero malesuada dignissim. Aliquam et hendrerit erat, non aliquet mi. Ut eu urna ligula. Donec mattis sollicitudin purus. Proin tellus libero, interdum porta mauris ac, interdum gravida sapien. Proin maximus purus ut dui ultrices, eget blandit est consectetur.") + msg.pack(fill='both', expand=True) + root.mainloop() \ No newline at end of file diff --git a/main.py b/main.py index dd9dec1..ee1fc27 100644 --- a/main.py +++ b/main.py @@ -1,1366 +1,1234 @@ # This file is a part of: -#‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ -# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ -# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ -# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ -# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ -#__________________________________________________________________________________ +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# __________________________________________________________________________________ # NBSTool is a tool to work with .nbs (Note Block Studio) files. # Author: IoeCmcomc (https://github.com/IoeCmcomc) # Programming language: Python # License: MIT license -# Version: 0.7.0 # Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) -#‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -import sys, os, operator, webbrowser, copy, traceback, re, json +import sys +import os +import operator +import webbrowser +import copy +import traceback +import re +import json +import asyncio + +from pathlib import Path +from ast import literal_eval import tkinter as tk import tkinter.ttk as ttk -#import tkinter.messagebox as tkmsgbox -from tkinter.filedialog import askopenfilename, asksaveasfilename, askdirectory -from tkinter.scrolledtext import ScrolledText +from tkinter.messagebox import showerror +from tkinter.filedialog import askopenfilename, asksaveasfilename, askdirectory, askopenfilenames + +import pygubu +from pygubu import Builder +from pygubu.widgets.dialog import Dialog +# Explict import for PyInstaller +from pygubu.builder import ttkstdwidgets, tkstdwidgets +from pygubu.builder.widgets import tkscrollbarhelper, dialog, pathchooserinput +import customwidgets -from time import sleep, time +from time import time from pprint import pprint -from random import randrange +from random import randrange, randint from math import floor, log2 -from datetime import date -#from collections import deque +from datetime import timedelta +from copy import deepcopy from PIL import Image, ImageTk -from pydub import AudioSegment -from pydub.playback import _play_with_simpleaudio -import music21 as m21 -import music21.stream as m21s -import music21.instrument as m21i - -from attr import Attr -from nbsio import opennbs, writenbs, DataPostprocess + +from nbsio import NBS_VERSION, NbsSong from ncfio import writencf +from nbs2midi import nbs2midi +from musescore2nbs import musescore2nbs + +vaniNoteSounds = [ + {'filename': 'harp.ogg', 'name': 'harp'}, + {'filename': 'dbass.ogg', 'name': 'bass'}, + {'filename': 'bdrum.ogg', 'name': 'basedrum'}, + {'filename': 'sdrum.ogg', 'name': 'snare'}, + {'filename': 'click.ogg', 'name': 'hat'}, + {'filename': 'guitar.ogg', 'name': 'guitar'}, + {'filename': 'flute.ogg', 'name': 'flute'}, + {'filename': 'bell.ogg', 'name': 'bell'}, + {'filename': 'icechime.ogg', 'name': 'chime'}, + {'filename': 'xylobone.ogg', 'name': 'xylophone'}, + {'filename': 'iron_xylophone.ogg', 'name': 'iron_xylophone'}, + {'filename': 'cow_bell.ogg', 'name': 'cow_bell'}, + {'filename': 'didgeridoo.ogg', 'name': 'didgeridoo'}, + {'filename': 'bit.ogg', 'name': 'bit'}, + {'filename': 'banjo.ogg', 'name': 'banjo'}, + {'filename': 'pling.ogg', 'name': 'pling'} +] + +globalIncVar = 0 -#Credit: https://stackoverflow.com/questions/42474560/pyinstaller-single-exe-file-ico-image-in-title-of-tkinter-main-window def resource_path(*args): - if len(args) > 1: - relative_path = os.path.join(*args) - else: relative_path = args[0] - if getattr(sys, 'frozen', False): - datadir = os.path.dirname(sys.executable) - r = os.path.join(datadir, relative_path) - else: - try: - r = os.path.join(sys._MEIPASS, relative_path) - except Exception: - r = os.path.join(os.path.abspath("."), relative_path) - #print(r) - return r - -class MainWindow(tk.Frame): - def __init__(self, parent): - tk.Frame.__init__(self, parent) - self.parent = parent - self.properties() - self.elements() - self.WindowBind() - self.toggleExpOptiGrp() - #self.update() - self.pack(fill='both', expand=True) - self.update() - WindowGeo(self.parent, self.parent, 800, 500, 600, 500) - self.lift() - self.focus_force() - self.grab_set() - self.grab_release() - - def properties(self): - self.VERSION = '0.7.0' - self.filePath = None - self.inputFileData = None - self.noteSounds = None - self.last = Attr() - self.last.inputFileData = None - self.var = Attr() - self.PlayingState = 'stop' - self.PlayingTick = -1 - self.SongPlayerAfter = None - self.exportFilePath = tk.StringVar() - - def elements(self): - self.parent.title("NBS Tool") - self.style = ttk.Style() - #self.style.theme_use("default") - try: - self.style.theme_use("vista") - except Exception as e: - print(repr(e), e.__class__.__name__) - self.style.theme_use("winnative") - - #Menu bar - self.menuBar = tk.Menu(self) - self.parent.configure(menu=self.menuBar) - self.menus() - - #Tabs - self.NbTabs = ttk.Notebook(self) - self.tabs() - self.NbTabs.enable_traversal() - self.NbTabs.pack(fill='both', expand=True) - - #Footer - tk.Frame(self, height=5).pack() - - self.footer = tk.Frame(self, relief='groove', borderwidth=1, height=25, width=self.winfo_width()) - self.footer.pack_propagate(False) - self.footerElements() - self.footer.pack(side='top', fill='x') - - def menus(self): - # 'File' menu - self.fileMenu = tk.Menu(self.menuBar, tearoff=False) - self.menuBar.add_cascade(label="File", menu=self.fileMenu) - - self.fileMenu.add_command(label="Open", accelerator="Ctrl+O", command = lambda: self.OnBrowseFile(True)) - self.fileMenu.add_command(label="Save", accelerator="Ctrl+S", command=self.OnSaveFile) - self.fileMenu.add_command(label="Save as new file", accelerator="Ctrl+Shift+S", command = lambda: self.OnSaveFile(True)) - self.fileMenu.add_separator() - self.fileMenu.add_command(label="Quit", accelerator="Esc", command=self.onClose) - - self.helpMenu = tk.Menu(self.menuBar, tearoff=False) - self.menuBar.add_cascade(label="Help", menu=self.helpMenu) - - self.helpMenu.add_command(label="About", command=lambda: AboutWindow(self)) - - def tabs(self): - #"General" tab - self.GeneralTab = tk.Frame(self.NbTabs) - - self.GeneralTab.rowconfigure(0) - self.GeneralTab.rowconfigure(1, weight=1) - - self.GeneralTab.columnconfigure(0, weight=1, uniform='a') - self.GeneralTab.columnconfigure(1, weight=1, uniform='a') - - self.GeneralTabElements() - self.NbTabs.add(self.GeneralTab, text="General") - - #"Play" tab - self.PlayTab = tk.Frame(self.NbTabs) - - self.PlayTabElements() - self.NbTabs.add(self.PlayTab, text="Play") - - #"Tools" tab - self.ToolsTab = tk.Frame(self.NbTabs) - - self.ToolsTab.rowconfigure(0, weight=1, uniform='b') - self.ToolsTab.rowconfigure(1, weight=1, uniform='b') - self.ToolsTab.rowconfigure(2) - - self.ToolsTab.columnconfigure(0, weight=1, uniform='b') - self.ToolsTab.columnconfigure(1, weight=1, uniform='b') - - self.ToolsTabElements() - self.NbTabs.add(self.ToolsTab, text="Tools") - - #"Export" tab - self.ExportTab = tk.Frame(self.NbTabs) - - self.ExportTabElements() - self.NbTabs.add(self.ExportTab, text="Export") - - def GeneralTabElements(self): - fpadx, fpady = 10, 10 - padx, pady = 5, 5 - - #"Open file" frame - self.OpenFileFrame = tk.Frame(self.GeneralTab, relief='ridge', borderwidth=1) - self.OpenFileFrame.grid(row=0, columnspan=2, sticky='nsew') - - self.OpenFileLabel = tk.Label(self.OpenFileFrame, text="Open file:", anchor='w', width=8) - self.OpenFileLabel.pack(side='left', padx=padx, pady=pady) - - self.OpenFileEntry = tk.Entry(self.OpenFileFrame) - self.OpenFileEntry.pack(side='left', fill='x', padx=padx, expand=True) - - self.BrowseFileButton = ttk.Button(self.OpenFileFrame, text="Browse", command = lambda: self.OnBrowseFile() ) - self.BrowseFileButton.pack(side='left', padx=padx, pady=pady) - - self.OpenFileButton = ttk.Button(self.OpenFileFrame, text="Open", command = lambda: self.OnOpenFile('', True) ) - self.OpenFileButton.pack(side='left', padx=padx, pady=pady) - - #File metadata frame - self.FileMetaFrame = tk.LabelFrame(self.GeneralTab, text="Metadata") - self.FileMetaFrame.grid(row=1, column=0, padx=fpadx, pady=fpady, sticky='nsew') - - self.FileMetaMess = tk.Message(self.FileMetaFrame, text="No flie was found.") - self.FileMetaMess.pack(fill='both', expand=True, padx=padx, pady=padx) - - #More infomation frame - self.FileInfoFrame = tk.LabelFrame(self.GeneralTab, text="Infomations") - self.FileInfoFrame.grid(row=1, column=1, padx=fpadx, pady=fpady, sticky='nsew') - - self.FileInfoMess = tk.Message(self.FileInfoFrame, text="No flie was found.") - self.FileInfoMess.pack(fill='both', expand=True, padx=padx, pady=pady) - - def PlayTabElements(self): - fpadx, fpady = 10, 10 - padx, pady = 5, 5 - - self.PlayCanvasFrame = tk.Frame(self.PlayTab, relief='groove', borderwidth=1) - self.PlayCanvasFrame.pack(fill='both', expand=True, padx=fpadx, pady=fpady) - - tk.Message(self.PlayCanvasFrame, text='Experimental feature', font=("Arial", 44)).pack(fill='both', expand=True) - - self.PlayCtrlFrame = tk.Frame(self.PlayTab, relief='groove', borderwidth=1) - self.PlayCtrlFrame.pack(fill='both', padx=fpadx, pady=fpady) - - self.PlayCtrlScale = tk.Scale(self.PlayCtrlFrame, from_=0, to=self.inputFileData['headers']['length'] if self.inputFileData is not None else 0, orient='horizontal', sliderlength=25) - self.PlayCtrlScale.pack(side='top', fill='x', expand=True) - self.PlayCtrlScale.bind("", lambda _: self.CtrlSongPlayer(state='pause')) - #self.PlayCtrlScale.bind("", lambda e: setattr(self, 'PlayingTick', e.widget.get())) - self.PlayCtrlScale.bind("", lambda _: self.CtrlSongPlayer(state='play')) - - self.CtrlBtnSW = StackingWidget(self.PlayCtrlFrame, relief='groove', borderwidth=1) - self.CtrlBtnSW.pack(side='left', anchor='sw', padx=padx, pady=pady) - - #self.PlaySongBtn = SquareButton(self.PlayCtrlFrame, text ='▶', size=20, command=lambda: self.CtrlSongPlayer(state='play')) - self.CtrlBtnSW.append(tk.Button(self.CtrlBtnSW, text ='Play', command=lambda: self.CtrlSongPlayer(state='play')), 'play') - self.CtrlBtnSW.pack('play') - - self.CtrlBtnSW.append(tk.Button(self.CtrlBtnSW, text ='Pause', command=lambda: self.CtrlSongPlayer(state='pause')), 'pause') - self.CtrlBtnSW.pack('pause') - - #self.StopSongBtn = SquareButton(self.PlayCtrlFrame, text ='⏹', size=20, command=lambda: self.CtrlSongPlayer(state='stop')) - self.StopSongBtn = tk.Button(self.PlayCtrlFrame, text ='Stop', command=lambda: self.CtrlSongPlayer(state='stop')) - self.StopSongBtn.pack(side='left', anchor='sw', padx=padx, pady=pady) - - def ToolsTabElements(self): - fpadx, fpady = 10, 10 - padx, pady = 5, 0 - - #Flip tool - self.FlipToolFrame = tk.LabelFrame(self.ToolsTab, text="Flipping") - self.FlipToolFrame.grid(row=0, column=0, sticky='nsew', padx=fpadx, pady=fpady) - - self.FlipToolMess = tk.Message(self.FlipToolFrame, anchor='w', text="Flip the note sequence horizontally (by tick), vertically (by layer) or both: ") - self.FlipToolMess.pack(fill='both', expand=True, padx=padx, pady=pady) - - self.var.tool.flip.vertical = tk.IntVar() - self.FilpToolCheckV = tk.Checkbutton(self.FlipToolFrame, text="Vertically", variable=self.var.tool.flip.vertical) - self.FilpToolCheckV.pack(side='left', padx=padx, pady=pady) - - self.var.tool.flip.horizontal = tk.IntVar() - self.FilpToolCheckH = tk.Checkbutton(self.FlipToolFrame, text="Horizontally", variable=self.var.tool.flip.horizontal) - self.FilpToolCheckH.pack(side='left', padx=padx, pady=pady) - - #Instrument tool - self.InstToolFrame = tk.LabelFrame(self.ToolsTab, text="Note's instrument") - self.InstToolFrame.grid(row=0, column=1, sticky='nsew', padx=fpadx, pady=fpady) - - self.InstToolMess = tk.Message(self.InstToolFrame, anchor='w', text="Change all note's instrument to:") - self.InstToolMess.pack(fill='both', expand=True, padx=padx, pady=pady) - - self.var.tool.opts = opts = ["(not applied)"] + [x['name'] for x in vaniNoteSounds] + ["Random"] - self.InstToolCombox = ttk.Combobox(self.InstToolFrame, state='readonly', values=opts) - self.InstToolCombox.current(0) - self.InstToolCombox.pack(side='left', fill='both' ,expand=True, padx=padx, pady=pady) - - #Reduce tool - self.ReduceToolFrame = tk.LabelFrame(self.ToolsTab, text="Reducing") - self.ReduceToolFrame.grid(row=1, column=0, sticky='nsew', padx=fpadx, pady=fpady) - - self.ReduceToolMess = tk.Message(self.ReduceToolFrame, anchor='w', text="Delete as many note as possible to reduce file size.") - self.ReduceToolMess.pack(fill='both', expand=True, padx=padx, pady=pady) - - self.var.tool.reduce.opt1 = tk.IntVar() - self.CompactToolChkOpt1 = FlexCheckbutton(self.ReduceToolFrame, text="Delete duplicate notes", variable=self.var.tool.reduce.opt1, anchor='w') - self.CompactToolChkOpt1.pack(padx=padx, pady=pady) - - self.var.tool.reduce.opt2 = tk.IntVar() - self.CompactToolChkOpt2 = FlexCheckbutton(self.ReduceToolFrame, text=" In every tick, delete all notes except the first note", variable=self.var.tool.reduce.opt2, anchor='w') - self.CompactToolChkOpt2.pack(padx=padx, pady=pady) - - self.var.tool.reduce.opt3 = tk.IntVar() - self.CompactToolChkOpt3 = FlexCheckbutton(self.ReduceToolFrame, text=" In every tick, delete all notes except the last note", variable=self.var.tool.reduce.opt3, anchor='w') - self.CompactToolChkOpt3.pack(padx=padx, pady=(pady, 10)) - - #Compact tool - self.CompactToolFrame = tk.LabelFrame(self.ToolsTab, text="Compacting") - self.CompactToolFrame.grid(row=1, column=1, sticky='nsew', padx=fpadx, pady=fpady) - - self.CompactToolMess = tk.Message(self.CompactToolFrame, anchor='w', text="Remove spaces between notes vertically (by layer) and group them by instruments.") - self.CompactToolMess.pack(fill='both', expand=True, padx=padx, pady=pady) - - self.var.tool.compact = tk.IntVar() - self.CompactToolCheck = FlexCheckbutton(self.CompactToolFrame, text="Compact notes", variable=self.var.tool.compact, command=self.toggleCompactToolOpt, anchor='w') - self.CompactToolCheck.pack(padx=padx, pady=pady) - - self.var.tool.compact.opt1 = tk.IntVar() - self.CompactToolChkOpt1 = FlexCheckbutton(self.CompactToolFrame, text="Automatic separate notes by instruments (remain some spaces)", variable=self.var.tool.compact.opt1, state='disabled', command=lambda: self.toggleCompactToolOpt(2), anchor='w') - self.CompactToolChkOpt1.select() - self.CompactToolChkOpt1.pack(padx=padx*5, pady=pady) - - self.var.tool.compact.opt1_1 = tk.IntVar() - self.CompactToolChkOpt1_1 = FlexCheckbutton(self.CompactToolFrame, text="Group percussions into one layer", variable=self.var.tool.compact.opt1_1, state='disabled', anchor='w') - self.CompactToolChkOpt1_1.select() - self.CompactToolChkOpt1_1.pack(padx=padx*5*2, pady=pady) - - #'Apply' botton - self.ToolsTabButton = ttk.Button(self.ToolsTab, text="Apply", state='disabled', command = self.OnApplyTool ) - self.ToolsTabButton.grid(row=2, column=1, sticky='se', padx=fpadx, pady=fpady) - - def ExportTabElements(self): - fpadx, fpady = 10, 10 - padx, pady = 5, 5 - - #Upper frame - self.ExpConfigFrame = tk.LabelFrame(self.ExportTab, text="Option") - self.ExpConfigFrame.pack(fill='both', expand=True, padx=fpadx, pady=fpady) - - #"Select mode" frame - self.ExpConfigGrp1 = tk.Frame(self.ExpConfigFrame, relief='groove', borderwidth=1) - self.ExpConfigGrp1.pack(fill='both', padx=fpadx) - - self.ExpConfigLabel = tk.Label(self.ExpConfigGrp1, text="Export the song as a:", anchor='w') - self.ExpConfigLabel.pack(side='left', fill='x', padx=padx, pady=pady) - - self.var.export.mode = tk.IntVar() - self.ExpConfigMode1 = tk.Radiobutton(self.ExpConfigGrp1, text="File", variable=self.var.export.mode, value=1) - self.ExpConfigMode1.pack(side='left', padx=padx, pady=pady) - self.ExpConfigMode1.select() - self.ExpConfigMode2 = tk.Radiobutton(self.ExpConfigGrp1, text="Datapack", variable=self.var.export.mode, value=0) - self.ExpConfigMode2.pack(side='left', padx=padx, pady=pady) - - self.var.export.type.file = \ - [('Musical Instrument Digital files', '*.mid'), - ('Nokia Composer Format', '*.txt'), - ('MPEG-1 Layer 3', '*.mp3'), - ('Waveform Audio File Format', '*.wav'), - ('Ogg Vorbis files', '*.ogg'), - ('Free Lossless Audio Codec files', '*.flac')] - self.var.export.type.dtp = ['Wireless note block song', 'other'] - self.ExpConfigCombox = ttk.Combobox(self.ExpConfigGrp1, state='readonly', values=["{} ({})".format(tup[0], tup[1]) for tup in self.var.export.type.file]) - self.ExpConfigCombox.current(0) - self.ExpConfigCombox.bind("<>", self.toggleExpOptiGrp) - self.ExpConfigCombox.pack(side='left', fill='x', padx=padx, pady=pady) - - self.var.export.mode.trace('w', self.toggleExpOptiGrp) - - ttk.Separator(self.ExpConfigFrame, orient="horizontal").pack(fill='x', expand=False, padx=padx*3, pady=pady) - - self.ExpOptiSW = StackingWidget(self.ExpConfigFrame, relief='groove', borderwidth=1) - self.ExpOptiSW.pack(fill='both', expand=True, padx=fpadx) - - #Midi export options frame - self.ExpOptiSW.append(tk.Frame(self.ExpOptiSW), 'Midi') - self.ExpOptiSW.pack('Midi', side='top', fill='both', expand=True) - - self.var.export.midi.opt1 = tk.IntVar() - self.ExpMidi1Rad1 = tk.Radiobutton(self.ExpOptiSW['Midi'], text="Sort notes to MIDI tracks by note's layer", variable=self.var.export.midi.opt1, value=1) - self.ExpMidi1Rad1.pack(anchor='nw', padx=padx, pady=(pady, 0)) - self.ExpMidi1Rad2 = tk.Radiobutton(self.ExpOptiSW['Midi'], text="Sort notes to MIDI tracks by note's instrument", variable=self.var.export.midi.opt1, value=0) - self.ExpMidi1Rad2.pack(anchor='nw', padx=padx, pady=(0, pady)) - - #Nokia export options frame - self.ExpOptiSW.append(tk.Frame(self.ExpOptiSW), 'NCF') - self.ExpOptiSW.pack('NCF', side='top', fill='both', expand=True) - - self.NCFOutput = ScrolledText(self.ExpOptiSW['NCF'], state="disabled", height=10) - self.NCFOutput.pack(fill='both', expand=True) - - #'Wireless song datapack' export options frame - self.ExpOptiSW.append(tk.Frame(self.ExpOptiSW), 'Wnbs') - self.ExpOptiSW.pack('Wnbs', side='top', fill='both', expand=True) - - self.WnbsIDLabel = tk.Label(self.ExpOptiSW['Wnbs'], text="Unique name:") - self.WnbsIDLabel.pack(anchor='nw', padx=padx, pady=pady) - - #vcmd = (self.register(self.onValidate), '%P') - self.WnbsIDEntry = tk.Entry(self.ExpOptiSW['Wnbs'], validate="key", - validatecommand=(self.register(lambda P: bool(re.match("^(\d|\w|[-_])*$", P))), '%P')) - self.WnbsIDEntry.pack(anchor='nw', padx=padx, pady=pady) - - #Other export options frame - self.ExpOptiSW.append(tk.Frame(self.ExpOptiSW), 'Other') - self.ExpOptiSW.pack('Other', side='top', fill='both', expand=True) - - self.ExpMusicLabel = tk.Label(self.ExpOptiSW['Other'], text="There is no option available.") - self.ExpMusicLabel.pack(anchor='nw', padx=padx, pady=pady) - - #Output frame - self.ExpOutputFrame = tk.LabelFrame(self.ExportTab, text="Output") - self.ExpOutputFrame.pack(fill='both', padx=fpadx, pady=(0, fpady)) - - self.ExpOutputLabel = tk.Label(self.ExpOutputFrame, text="File path:", anchor='w', width=8) - self.ExpOutputLabel.pack(side='left', fill='x', padx=padx, pady=pady) - - self.ExpOutputEntry = tk.Entry(self.ExpOutputFrame, textvariable=self.exportFilePath) - self.ExpOutputEntry.pack(side='left', fill='x', padx=padx, expand=True) - - self.ExpBrowseButton = ttk.Button(self.ExpOutputFrame, text="Browse", command = self.OnBrowseExp ) - self.ExpBrowseButton.pack(side='left', padx=padx, pady=pady) - - self.ExpSaveButton = ttk.Button(self.ExpOutputFrame, text="Export", command = self.OnExport ) - self.ExpSaveButton.pack(side='left', padx=padx, pady=pady) - - def footerElements(self): - self.footerLabel = tk.Label(self.footer, text="Ready") - self.footerLabel.pack(side='left', fill='x') - self.var.footerLabel = 0 - - self.sizegrip = ttk.Sizegrip(self.footer) - self.sizegrip.pack(side='right', anchor='se') - - self.progressbar = ttk.Progressbar(self.footer, orient="horizontal", length=300 ,mode="determinate") - self.progressbar["value"] = 0 - self.progressbar["maximum"] = 100 - #self.progressbar.start() - #self.progressbar.stop() - - def WindowBind(self): - #Keys - self.parent.bind('', self.onClose) - self.parent.bind('', lambda _: self.OnBrowseFile(True)) - self.parent.bind('', self.OnSaveFile) - self.parent.bind('', lambda _: self.OnSaveFile(True)) - self.parent.bind('', lambda _: self.OnSaveFile(True)) - - #Bind class - self.bind_class("Message" ,"", lambda e: e.widget.configure(width=e.width-10)) - - for tkclass in ('TButton', 'Checkbutton', 'Radiobutton'): - self.bind_class(tkclass, '', lambda e: e.widget.event_generate('', when='tail')) - - self.bind_class("TCombobox", "", lambda e: e.widget.event_generate('')) - - for tkclass in ("Entry", "Text", "ScrolledText"): - self.bind_class(tkclass ,"", self.popupmenus) - - self.bind_class("TNotebook", "<>", self._on_tab_changed) - - - #Credit: http://code.activestate.com/recipes/580726-tkinter-notebook-that-fits-to-the-height-of-every-/ - def _on_tab_changed(self,event): - event.widget.update_idletasks() - - tab = event.widget.nametowidget(event.widget.select()) - event.widget.configure(height=tab.winfo_reqheight()) - - def popupmenus(self, event): - w = event.widget - self.popupMenu = tk.Menu(self, tearoff=False) - self.popupMenu.add_command(label="Select all", accelerator="Ctrl+A", command=lambda: w.event_generate("")) - self.popupMenu.add_separator() - self.popupMenu.add_command(label="Cut", accelerator="Ctrl+X", command=lambda: w.event_generate("")) - self.popupMenu.add_command(label="Copy", accelerator="Ctrl+C", command=lambda: w.event_generate("")) - self.popupMenu.add_command(label="Paste", accelerator="Ctrl+V", command=lambda: w.event_generate("")) - self.popupMenu.tk.call("tk_popup", self.popupMenu, event.x_root, event.y_root) - - def onClose(self, event=None): - self.parent.quit() - self.parent.destroy() - - def OnBrowseFile(self, doOpen=False, filename=None): - types = [('Note Block Studio files', '*.nbs'), ('All files', '*')] - if filename is None: filename = askopenfilename(filetypes = types) - if self.filePath is None or bool(filename): - self.filePath = filename - self.OpenFileEntry.delete(0,'end') - self.OpenFileEntry.insert(0, filename) - if doOpen: - self.OnOpenFile(filename, None) - - def OnOpenFile(self, fileName, fromEntry=False): - if fromEntry: fileName = self.OpenFileEntry.get() - self.UpdateProgBar(20) - if fileName != '': - data = opennbs(fileName) - if data is not None: - self.UpdateProgBar(80) - self.inputFileData = data - self.ExpOutputEntry.delete(0, 'end') - self.exportFilePath.set('') - print(type(data)) - - self.UpdateVar() - self.parent.title('"{}" – NBS Tool'.format(fileName.split('/')[-1])) - self.RaiseFooter('Opened') - self.UpdateProgBar(100) - self.UpdateProgBar(-1) - - def OnSaveFile(self, saveAsNewFile=False): - if self.inputFileData is not None: - if saveAsNewFile is True: - types = [('Note Block Studio files', '*.nbs'), ('All files', '*')] - self.filePath = asksaveasfilename(filetypes = types) - if not self.filePath.lower().endswith('.nbs'): self.filePath = self.filePath.split('.')[0] + '.nbs' - self.UpdateProgBar(50) - - writenbs(self.filePath, self.inputFileData) - - if saveAsNewFile is True: - self.OpenFileEntry.delete(0,'end') - self.OpenFileEntry.insert(0, self.filePath) - - self.parent.title('"{}" – NBS Tool'.format(self.filePath.split('/')[-1])) - self.UpdateProgBar(100) - self.RaiseFooter('Saved') - self.UpdateProgBar(-1) - - def toggleCompactToolOpt(self, id=1): - if id <= 2: - a = ((self.var.tool.compact.opt1.get() == 0) or (self.var.tool.compact.get() == 0)) - self.CompactToolChkOpt1_1["state"] = "disable" if a is True else "normal" - if id <= 1: - self.CompactToolChkOpt1["state"] = "disable" if self.var.tool.compact.get() == 0 else "normal" - - def CtrlSongPlayer(self, event=None, state=None, repeat=False): - if self.inputFileData is None: return - hds = self.inputFileData['headers'] - notes = self.inputFileData['notes'] - layers = self.inputFileData['layers'] - tickIndexes = self.inputFileData['indexByTick'] - noteSounds = self.noteSounds - - if state == 'play' and self.PlayingState != 'play' or repeat and self.SongPlayerAfter is not None: - if self.PlayingTick <= hds['length'] - 1: - self.PlayingState = 'play' - self.CtrlBtnSW.switch('pause') - self.PlayingTick = int(self.PlayCtrlScale.get()) - self.PlayCtrlScale.set(self.PlayingTick + 1) - - toUnsigned = lambda x: 256 + x if x < 0 else x - tickSoundLength = max(len(x['obj']) for x in noteSounds) - currNotes = tickIndexes[self.PlayingTick][1] - SoundToPlay = None - - for n, i in enumerate(currNotes ): - note = notes[i] - currLayer = layers[note['layer']] - inst = note['inst'] - - currNoteSound = noteSounds[inst] - noteSoundObj = currNoteSound['obj'] - - ANoteSound = noteSoundObj._spawn(noteSoundObj.raw_data, overrides={'frame_rate': int(noteSoundObj.frame_rate * (2.0 ** ((note['key'] - currNoteSound['pitch']) / 12))) }).set_frame_rate(44100) - if 0 < currLayer['volume'] < 100: ANoteSound = ANoteSound.apply_gain(noteSoundObj.dBFS - noteSoundObj.dBFS * (currLayer['volume'] / 100)) + 3 - if currLayer['stereo'] != 100: ANoteSound = ANoteSound.pan((toUnsigned(currLayer['stereo']) - 100) / 100) - - if len(currNotes) == 1: SoundToPlay = ANoteSound - elif n == 0: SoundToPlay = ANoteSound + AudioSegment.silent(duration=tickSoundLength - len(ANoteSound)) - else: SoundToPlay = SoundToPlay.overlay(ANoteSound) - - if len(currNotes) > 0: _play_with_simpleaudio(SoundToPlay.set_frame_rate(44100)) - - #self.SongPlayerAfter = self.after(40, lambda: self.CtrlSongPlayer(state='play', repeat=True) ) - self.SongPlayerAfter = self.after(int(1000 / hds['tempo'] * 0.9), lambda: self.CtrlSongPlayer(state='play', repeat=True) ) - else: - state = 'stop' - - if state == 'pause' and self.PlayingState != 'pause': - if self.PlayingState == 'stop': return - self.PlayingState = 'pause' - self.CtrlBtnSW.switch('play') - self.after_cancel(self.SongPlayerAfter) - self.SongPlayerAfter = None - - if state == 'stop' and self.PlayingState != 'stop' and self.SongPlayerAfter: - self.PlayingState = 'stop' - self.PlayingTick = -1 - self.CtrlBtnSW.switch('play') - self.after_cancel(self.SongPlayerAfter) - self.SongPlayerAfter = None - self.PlayCtrlScale.set(0) - - - def OnApplyTool(self): - self.ToolsTabButton['state'] = 'disabled' - self.UpdateProgBar(0) - data = self.inputFileData - ticklen = data['headers']['length'] - layerlen = data['maxLayer'] - instOpti = self.InstToolCombox.current() - self.UpdateProgBar(20) - for note in data['notes']: - #Flip - if bool(self.var.tool.flip.horizontal.get()): note['tick'] = ticklen - note['tick'] - if bool(self.var.tool.flip.vertical.get()): note['layer'] = layerlen - note['layer'] - - #Instrument change - if instOpti > 0: - note['inst'] = randrange(len(self.var.tool.opts)-2) if instOpti > len(self.noteSounds) else instOpti-1 - self.UpdateProgBar(30) - #Reduce - if bool(self.var.tool.reduce.opt2.get()) and bool(self.var.tool.reduce.opt3.get()): - data['notes'] = [note for i, note in enumerate(data['notes']) if note == data['notes'][-1] or note['tick'] != data['notes'][i-1]['tick'] or note['tick'] != data['notes'][i+1]['tick']] - elif bool(self.var.tool.reduce.opt2.get()): - data['notes'] = [note for i, note in enumerate(data['notes']) if note['tick'] != data['notes'][i-1]['tick']] - elif bool(self.var.tool.reduce.opt3.get()): - data['notes'] = [data['notes'][i-1] for i, note in enumerate(data['notes']) if note['tick'] != data['notes'][i-1]['tick']] - self.UpdateProgBar(60) - if bool(self.var.tool.reduce.opt1.get()): - data['notes'] = sorted(data['notes'], key = operator.itemgetter('tick', 'inst', 'key', 'layer') ) - data['notes'] = [note for i, note in enumerate(data['notes']) if note['tick'] != data['notes'][i-1]['tick'] or note['inst'] != data['notes'][i-1]['inst'] or note['key'] != data['notes'][i-1]['key']] - data['notes'] = sorted(data['notes'], key = operator.itemgetter('tick', 'layer') ) - - self.UpdateProgBar(50) - #Compact - if bool(self.var.tool.compact.get()): data = compactNotes(data, self.var.tool.compact.opt1.get(), self.var.tool.compact.opt1_1.get()) - self.UpdateProgBar(60) - #Sort notes - data['notes'] = sorted(data['notes'], key = operator.itemgetter('tick', 'layer') ) - - self.UpdateProgBar(60) - data = DataPostprocess(data) - - self.UpdateProgBar(90) - self.UpdateVar() - #self.UpdateProgBar(100) - self.RaiseFooter('Applied') - self.UpdateProgBar(-1) - self.ToolsTabButton['state'] = 'normal' - - def UpdateVar(self): - #print("Started updating….") - data = self.inputFileData - if data is not None: - self.ToolsTabButton['state'] = 'normal' - self.ExpSaveButton['state'] = 'normal' - if data != self.last.inputFileData: - self.UpdateProgBar(10) - self.parent.title('*"{}" – NBS Tool'.format(self.filePath.split('/')[-1])) - self.UpdateProgBar(20) - headers = data['headers'] - self.UpdateProgBar(30) - text = "File version: {}\nFirst custom inst index: {}\nSong length: {}\nSong height: {}\nSong name: {}\nSong author: {}\nComposer: {}\nSong description: {}\nTempo: {} TPS\nAuto-saving: {}\nTime signature: {}/4\nMinutes spent: {}\nLeft clicks: {}\nRight clicks: {}\nBlocks added: {}\nBlocks removed: {}\nMIDI/Schematic file name: {}".format \ - ( headers['file_version'], headers['vani_inst'], headers['length'], headers['height'], headers['name'], headers['orig_author'], headers['author'], headers['description'], headers['tempo'], "Enabled (save every {} minutes(s)".format(headers['auto-saving_time']) if headers['auto-saving'] else "Disabled", headers['time_sign'], headers['minutes_spent'], headers['left_clicks'], headers['right_clicks'], headers['block_added'], headers['block_removed'], headers['import_name'] ) - self.FileMetaMess.configure(text=text) - self.UpdateProgBar(40) - text = "File format: {}\nHas percussion: {}\nMax layer with at least 1 note block: {}\nCustom instrument(s): {}".format \ - ( "Old" if data['IsOldVersion'] else "New", data['hasPerc'], data['maxLayer'], headers['inst_count'] ) - self.FileInfoMess.configure(text=text) - self.UpdateProgBar(50) - self.PlayCtrlScale['to'] = self.inputFileData['headers']['length'] - self.UpdateProgBar(60) - customInsts = [ {'name': item['name'], 'filepath': resource_path('sounds', item['filename']), 'obj': AudioSegment.from_ogg(resource_path('sounds', item['filename'])), 'pitch': item['pitch']} for item in data['customInsts']] - self.noteSounds = vaniNoteSounds + customInsts - self.UpdateProgBar(70) - self.var.tool.opts = opts = ["(not applied)"] + [x['name'] for x in self.noteSounds] + ["Random"] - self.InstToolCombox.configure(values=opts) - self.UpdateProgBar(80) - if data['maxLayer'] == 0: - text = writencf(data) - else: - text = "The song must have only 1 layer in order to export as Nokia Composer Format." - self.NCFOutput.configure(state='normal') - self.NCFOutput.delete('1.0', 'end') - self.NCFOutput.insert('end', text) - self.NCFOutput.configure(state='disabled') - self.UpdateProgBar(100) - self.last.inputFileData = copy.deepcopy(data) - self.RaiseFooter('Updated') - #print("Updated class properties…", data == self.last.inputFileData) - - self.UpdateProgBar(-1) - else: - self.ToolsTabButton['state'] = 'disabled' - self.ExpSaveButton['state'] = 'disabled' - - self.update_idletasks() - - def toggleExpOptiGrp(self ,n=None, m=None, y=None): - asFile = bool(self.var.export.mode.get()) - key = max(0, self.ExpConfigCombox.current()) - if asFile: - if key == 0: self.ExpOptiSW.switch('Midi') - elif key == 1: self.ExpOptiSW.switch('NCF') - else: self.ExpOptiSW.switch('Other') - self.ExpConfigCombox.configure(values=["{} ({})".format(tup[0], tup[1]) for tup in self.var.export.type.file]) - else: - if key == 0: self.ExpOptiSW.switch('Wnbs') - else: self.ExpOptiSW.switch('Other') - self.ExpConfigCombox.configure(values=["{} ({})".format(tup[0], tup[1]) if isinstance(tup, tuple) else tup for tup in self.var.export.type.dtp]) - key = min(key, len(self.ExpConfigCombox['values'])-1) - self.ExpConfigCombox.current(key) - self.ExpConfigCombox.configure(width=len(self.ExpConfigCombox.get())) - - if self.exportFilePath.get(): - print(self.exportFilePath.get()) - fext = (self.var.export.type.file[self.ExpConfigCombox.current()],)[0][1][1:] - if asFile: - if not self.exportFilePath.get().lower().endswith(self.WnbsIDEntry.get()): self.exportFilePath.set( "{}/{}".format(self.exportFilePath.get(), self.WnbsIDEntry.get()) ) - self.WnbsIDEntry.delete(0, 'end') - if not self.exportFilePath.get().lower().endswith(fext): self.exportFilePath.set( self.exportFilePath.get().split('.')[0] + fext ) - else: - if '.' in self.exportFilePath.get(): - self.WnbsIDEntry.delete(0, 'end') - self.WnbsIDEntry.insert(0, self.exportFilePath.get().split('/')[-1].split('.')[0]) - self.exportFilePath.set( '/'.join(self.exportFilePath.get().split('/')[0:-1]) ) - - def OnBrowseExp(self): - if self.filePath and self.inputFileData: - asFile = bool(self.var.export.mode.get()) - if asFile: - curr = (self.var.export.type.file[self.ExpConfigCombox.current()],) - fext = curr[0][1][1:] - self.exportFilePath.set( asksaveasfilename(title="Export file", initialfile=os.path.splitext(os.path.basename(self.filePath))[0]+fext, filetypes=curr) ) - else: - curr = [(self.var.export.type.dtp[self.ExpConfigCombox.current()], '*.'),] - fext ='' - self.exportFilePath.set( askdirectory(title="Export datapack (choose the directory to put the datapack)", initialdir=os.path.dirname(self.filePath), mustexist=False) ) - if self.exportFilePath.get(): - if asFile: - if not self.exportFilePath.get().lower().endswith(fext): self.exportFilePath.set( self.exportFilePath.get().split('.')[0] + fext ) - else: - if '.' in self.exportFilePath.get().split('/')[-1]: - self.exportFilePath.set( '/'.join(self.exportFilePath.get().split('/')[0:-1]) ) - - def OnExport(self): - if self.exportFilePath.get() is not None: - self.ExpBrowseButton['state'] = self.ExpSaveButton['state'] = 'disabled' - self.UpdateProgBar(10) - data, path = self.inputFileData, self.exportFilePath.get() - asFile = bool(self.var.export.mode.get()) - type = self.ExpConfigCombox.current() - if asFile: - if type == 0: - exportMIDI(self, path, self.var.export.midi.opt1.get()) - elif type == 1: - with open(path, "w") as f: - f.write(writencf(data)) - elif type in {2, 3, 4, 5}: - fext = self.var.export.type.file[self.ExpConfigCombox.current()][1][2:] - exportMusic(self, path, fext) - else: - if type == 0: - exportDatapack(self, os.path.join(path, self.WnbsIDEntry.get()), 'wnbs') - - #self.UpdateProgBar(100) - self.RaiseFooter('Exported') - self.UpdateProgBar(-1) - - self.ExpBrowseButton['state'] = self.ExpSaveButton['state'] = 'normal' - - def UpdateProgBar(self, value, time=0.001): - if value != self.progressbar["value"]: - if value == -1 or time < 0: - self.progressbar.pack_forget() - self.configure(cursor='arrow') - else: - self.configure(cursor='wait') - self.progressbar["value"] = value - self.progressbar.pack(side='right') - self.progressbar.update() - if time != 0: sleep(time) - - def RaiseFooter(self, text='', color='green', hid=False): - if hid == False: - #self.RaiseFooter(hid=True) - text.replace('\s', ' ') - self.footerLabel.configure(text=text, foreground=color) - self.footerLabel.pack(side='left', fill='x') - self.after(999, lambda: self.RaiseFooter(text=text, color=color, hid=True)) - else: - self.footerLabel.pack_forget() - self.footerLabel.update() - - -class AboutWindow(tk.Toplevel): - def __init__(self, parent): - tk.Toplevel.__init__(self) - self.parent = parent - self.title("About this application...") - - self.logo = ImageTk.PhotoImage(Image.open(resource_path('icon.ico')).resize((128 ,128), Image.ANTIALIAS)) - - logolabel = tk.Label(self, text="NBSTool", font=("Arial", 44), image=self.logo, compound='left') - logolabel.pack(padx=30, pady=(10*2, 10)) - - description = tk.Message(self, text="A tool to work with .nbs (Note Block Studio) files.\nAuthor: IoeCmcomc\nVersion: {}".format(parent.VERSION), justify='center') - description.pack(fill='both', expand=False, padx=10, pady=10) - - githubLink = ttk.Button(self, text='GitHub', command= lambda: webbrowser.open("https://github.com/IoeCmcomc/NBSTool",new=True)) - githubLink.pack(padx=10, pady=10) - - self.lift() - self.focus_force() - self.grab_set() - #self.grab_release() - - self.resizable(False, False) - self.transient(self.parent) - - self.bind("", self.Alarm) - self.bind('', lambda _: self.destroy()) - - self.iconbitmap(resource_path("icon.ico")) - - WindowGeo(self, parent, 500, 300) - - def Alarm(self, event): - self.focus_force() - self.bell() + if getattr(sys, 'frozen', False): + r = os.path.join(sys._MEIPASS, *args) + else: + r = os.path.join(os.path.abspath('.'), *args) + return r + + +class MainWindow(): + def __init__(self): + builder: Builder = pygubu.Builder() + self.builder: Builder = builder + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/toplevel.ui')) + self.toplevel: tk.Toplevel = builder.get_object('toplevel') + self.mainwin: tk.Frame = builder.get_object('mainFrame') + style = ttk.Style(self.toplevel) + style.layout('Barless.TNotebook.Tab', []) # turn off tabs + style.configure('Barless.TNotebook', borderwidth=0, highlightthickness=0) + + self.fileTable: ttk.Treeview = builder.get_object('fileTable') + applyBtn = builder.get_object('applyBtn') + + self.toplevel.title("NBS Tool") + centerToplevel(self.toplevel) + + self.initMenuBar() + + builder.import_variables(self) + builder.connect_callbacks(self) + + self.initFormatTab() + self.initHeaderTab() + self.initFlipTab() + self.initArrangeTab() + self.windowBind() + + def on_fileTable_select(event): + fileTable: ttk.Treeview = event.widget + if fileTable['selectmode'] == 'none': + return + selection: tuple = fileTable.selection() + selectionLen = len(selection) + selectionNotEmpty = selectionLen > 0 + if selectionNotEmpty: + builder.get_object('fileMenu').entryconfig(1, state="normal") + builder.get_object('saveFilesBtn')["state"] = "normal" + builder.get_object('removeEntriesBtn')["state"] = "normal" + self.updateHeaderNotebook([fileTable.index(item) for item in selection]) + applyBtn["state"] = "normal" + else: + builder.get_object('fileMenu').entryconfig(1, state="disabled") + builder.get_object('saveFilesBtn')["state"] = "disabled" + builder.get_object('removeEntriesBtn')["state"] = "disabled" + builder.get_object('headerNotebook').select(0) + self.selectedFilesVerStr.set("No file are selected.") + applyBtn["state"] = "disabled" + exportMenu: tk.Menu = builder.get_object('exportMenu') + exportMenu.entryconfig(0, state="normal" if selectionLen == 1 else "disable") + exportMenu.entryconfig(1, state="normal" if selectionNotEmpty else "disable") + + self.fileTable.bind("<>", on_fileTable_select) + + self.mainwin.lift() + self.mainwin.focus_force() + self.mainwin.grab_set() + self.mainwin.grab_release() + + self.VERSION = '1.0.0' + self.filePaths = [] + self.songsData = [] + self.selectedFilesVersion = -1 + + def isInteger(self, value) -> bool: + print("isInteger", value, value == '' or value.isdigit()) + return value == '' or value.isdigit() + + def getSelectedFilesVersion(self, selection: tuple) -> int: + fileVersion = -1 + for i in selection: + header = self.songsData[i].header + ver: int = header.file_version + if (ver != fileVersion) and (fileVersion != -1): + return -1 + else: + fileVersion = ver + return fileVersion + + def updateHeaderNotebook(self, selection: tuple) -> None: + def updateCheckbutton(i: int, var: tk.StringVar, widget: ttk.Checkbutton, value: bool) -> bool: + ret = (i > 0) and (var.get() != value) + if ret: + widget.state(['alternate']) + else: + var.set(value) + return not ret + + def updateSpinbox(i: int, var: tk.StringVar, value: int) -> None: + var.set('' if ((i > 0) and (var.get() != str(value))) else value) + + get_object = self.builder.get_object + notebook: ttk.Notebook = self.builder.get_object('headerNotebook') + fileVersion = self.getSelectedFilesVersion(selection) + if fileVersion == -1: + notebook.select(0) + self.selectedFilesVerStr.set("Selected file(s) don't have the same version number.") + self.selectedFilesVersion = -1 + return + self.selectedFilesVersion = fileVersion + self.selectedFilesVerStr.set("Selected file(s) format version: {: >8}".format(fileVersion if fileVersion > 0 else 'Classic')) + notebook.select(1) + for i, index in enumerate(selection): + header = self.songsData[index].header + if fileVersion >= 4: + loop = header.loop + checkBox: ttk.Checkbutton = get_object('headerLoopCheck') + checkBox['state'] = 'normal' + if updateCheckbutton(i, self.headerLoop, checkBox, loop): + updateSpinbox(i, self.headerLoopCount, header.loop_max) + updateSpinbox(i, self.headerLoopStart, header.loop_start) + self.onLoopCheckBtn() + else: + # Credit: https://stackoverflow.com/questions/24942760/is-there-a-way-to-gray-out-disable-a-tkinter-frame + for child in get_object('headerLoopFrame').winfo_children(): + child.configure(state='disable') + + autoSave = header.auto_save + if updateCheckbutton(i, self.headerAutosave, get_object('headerAutosaveCheck'), autoSave): + updateSpinbox(i, self.headerAutosaveInterval, header.auto_save_time) + self.onAutosaveCheckBtn() + + updateSpinbox(i, self.headerMinuteSpent, header.minutes_spent) + updateSpinbox(i, self.headerLeftClicks, header.left_clicks) + updateSpinbox(i, self.headerRightClicks, header.right_clicks) + updateSpinbox(i, self.headerBlockAdded, header.block_added) + updateSpinbox(i, self.headerBlockRemoved, header.block_removed) + + def onAutosaveCheckBtn(self): + label = self.builder.get_object('headerAutosaveLabel') + spinbox = self.builder.get_object('headerAutosaveSpin') + state = 'normal' if ((not 'alternate' in self.builder.get_object('headerAutosaveCheck').state()) and self.headerAutosave.get()) else 'disabled' + label['state'] = state + spinbox['state'] = state + + def onLoopCheckBtn(self): + checkBox: ttk.Checkbutton = self.builder.get_object('headerLoopCheck') + loop = self.headerLoop.get() + state = 'normal' if ((not 'alternate' in self.builder.get_object('headerLoopCheck').state()) and self.headerLoop.get()) else 'disabled' + for child in self.builder.get_object('headerLoopFrame').winfo_children(): + if child is not checkBox: + child.configure(state=state) + + def initMenuBar(self): + self.menuBar = menuBar = self.builder.get_object('menubar') + self.toplevel.configure(menu=menuBar) + + def disabledFileTable(self): + self.fileTable.state(('disabled',)) + self.fileTable['selectmode'] = 'none' + + def enableFileTable(self): + self.fileTable.state(('!disabled',)) + self.fileTable['selectmode'] = 'extended' + + def openFiles(self, _=None): + self.fileTable.delete(*self.fileTable.get_children()) + self.filePaths.clear() + self.songsData.clear() + self.addFiles() + + def addFileInfo(self, filePath: str, songData: NbsSong) -> None: + global globalIncVar + if filePath == '': + globalIncVar += 1 + filePath = "[Not saved] ({})".format(globalIncVar) + header = songData.header + length = timedelta(seconds=floor( + header['length'] / header['tempo'])) if header['length'] != None else "Not calculated" + self.fileTable.insert("", 'end', text=filePath, values=( + length, header.name, header.author, header.orig_author)) + + def addFiles(self, _=None, paths=()): + types = [("Note Block Studio files", '*.nbs'), ('All files', '*')] + addedPaths = [] + if len(paths) > 0: + addedPaths = paths + else: + addedPaths = askopenfilenames(filetypes=types) + if len(addedPaths) == 0: + return + self.builder.get_object('applyBtn')['state'] = 'disabled' + self.disabledFileTable() + for i, filePath in enumerate(addedPaths): + try: + songData = NbsSong(filePath) + self.songsData.append(songData) + except Exception as e: + showerror("Opening file error", 'Cannot open file "{}"\n{}: {}'.format(filePath, e.__class__.__name__, e)) + print(traceback.format_exc()) + continue + self.addFileInfo(filePath, songData) + if i % 3 == 0: + self.mainwin.update() + self.filePaths.extend(addedPaths) + self.enableFileTable() + self.builder.get_object('applyBtn')['state'] = 'normal' + self.builder.get_object('fileMenu').entryconfig(2, state="normal" if len( + self.filePaths) > 0 else "disabled") + + def saveFiles(self, _=None): + if len(self.filePaths) == 0: + return + fileTable = self.fileTable + if len(selection := fileTable.selection()) > 0: + if len(selection) == 1: + i = fileTable.index(selection[0]) + filePath = self.filePaths[i] + types = [('Note Block Studio files', '*.nbs'), + ('All files', '*')] + fileName = '' + if filePath != '': + fileName = os.path.basename(filePath) + else: + fileName = self.songsData[i].header.import_name + path = asksaveasfilename( + filetypes=types, initialfile=fileName.rsplit('.', 1)[0], defaultextension=".nbs") + if path == '': + return + if filePath == '': + fileTable.item(selection[0], text=os.path.join(path)) + self.builder.get_object('applyBtn')['state'] = 'disabled' + self.disabledFileTable() + self.mainwin.update() + self.songsData[i].write(path) + self.enableFileTable() + self.builder.get_object('applyBtn')['state'] = 'normal' + return + else: + path = askdirectory(title="Select folder to save") + if path == '': + return + Path(path).mkdir(parents=True, exist_ok=True) + self.disabledFileTable() + for item in selection: + try: + i = fileTable.index(item) + filePath = self.filePaths[i] + if filePath == '': + filePath = self.songsData[i].header.import_name.rsplit('.', 1)[0] + '.nbs' + savedPath = os.path.join(path, filePath) + fileTable.item(item, text=savedPath) + self.filePaths[i] = savedPath + self.songsData[i].write(os.path.join(path, os.path.basename(filePath))) + except Exception as e: + showerror("Saving file error", 'Cannot save file "{}"\n{}: {}'.format(os.path.join(path, os.path.basename(filePath)), e.__class__.__name__, e)) + print(traceback.format_exc()) + self.enableFileTable() + self.builder.get_object('applyBtn')['state'] = 'normal' + + def saveAll(self, _=None): + if len(self.filePaths) == 0: + return + path = askdirectory(title="Select folder to save") + if path == '': + return + Path(path).mkdir(parents=True, exist_ok=True) + self.builder.get_object('applyBtn')['state'] = 'disabled' + self.disabledFileTable() + self.mainwin.update() + items = self.fileTable.get_children() + for i, filePath in enumerate(self.filePaths): + try: + if filePath == '': + filePath = self.songsData[i].header.import_name.rsplit('.', 1)[0] + '.nbs' + savedPath = os.path.join(path, filePath) + self.fileTable.item(items[i], text=savedPath) + self.filePaths[i] = savedPath + self.songsData[i].write(os.path.join(path, os.path.basename(filePath))) + except Exception as e: + showerror("Saving file error", 'Cannot save file "{}"\n{}: {}'.format(os.path.join(path, os.path.basename(filePath)), e.__class__.__name__, e)) + print(traceback.format_exc()) + self.enableFileTable() + self.builder.get_object('applyBtn')['state'] = 'normal' + + def removeSelectedFiles(self): + if len(self.filePaths) == 0: + return + fileTable = self.fileTable + if len(selection := fileTable.selection()) > 0: + for item in reversed(selection): + i = fileTable.index(item) + fileTable.delete(item) + del self.filePaths[i] + del self.songsData[i] + fileTable.selection_remove(fileTable.selection()) + + def initFormatTab(self): + combobox = self.builder.get_object('formatCombo') + combobox.configure( + values=("(not selected)", *range(NBS_VERSION, 1-1, -1), "Classic")) + combobox.current(0) + + def initHeaderTab(self): + self.builder.get_object('headerAutosaveCheck').state(['alternate']) + self.builder.get_object('headerLoopCheck').state(['alternate']) + + def initFlipTab(self): + self.builder.get_object('flipHorizontallyCheck').state(('!selected',)) + self.builder.get_object('flipVerticallyCheck').state(('!selected',)) + + def initArrangeTab(self): + self.arrangeMode.set('none') + # self.builder.get_object('arrangeGroupPrec').state(('!selected',)) + + def callMuseScoreImportDialog(self): + dialog = MuseScoreImportDialog(self.toplevel, self) + dialog.run() + del dialog + + def callDatapackExportDialog(self): + dialog = DatapackExportDialog(self.toplevel, self) + dialog.run() + del dialog + + def callMidiExportDialog(self): + dialog = MidiExportDialog(self.toplevel, self) + dialog.run() + del dialog + + def callAboutDialog(self): + dialog = AboutDialog(self.toplevel, self) + del dialog + + def windowBind(self): + toplevel = self.toplevel + mainwin = self.mainwin + # Keys + toplevel.bind('', self.onClose) + toplevel.bind('', self.openFiles) + toplevel.bind('', self.saveFiles) + toplevel.bind('', self.saveAll) + toplevel.bind('', self.saveAll) + + # Bind class + for tkclass in ('TButton', 'Checkbutton', 'Radiobutton'): + mainwin.bind_class(tkclass, '', lambda e: e.widget.event_generate( + '', when='tail')) + + mainwin.bind_class("TCombobox", "", + lambda e: e.widget.event_generate('')) + + for tkclass in ("Entry", "Text", "ScrolledText", "TCombobox"): + mainwin.bind_class(tkclass, "", self.popupmenus) + + # mainwin.bind_class("TNotebook", "<>", + # self._on_tab_changed) + + mainwin.bind_class("Treeview", "", + self._on_treeview_shift_down) + mainwin.bind_class("Treeview", "", + self._on_treeview_shift_up) + mainwin.bind_class("Treeview", "", + self._on_treeview_left_click, add='+') + + # Credit: http://code.activestate.com/recipes/580726-tkinter-notebook-that-fits-to-the-height-of-every-/ + def _on_tab_changed(self, event): + event.widget.update_idletasks() + + tab = event.widget.nametowidget(event.widget.select()) + event.widget.configure(height=tab.winfo_reqheight()) + + # Credit: https://stackoverflow.com/questions/57939932/treeview-how-to-select-multiple-rows-using-cursor-up-and-down-keys + def _on_treeview_shift_down(self, event): + tree: ttk.Treeview = event.widget + cur_item = tree.focus() + next_item = tree.next(cur_item) + if next_item == '': + return 'break' + selection = tree.selection() + if next_item in selection: + tree.selection_remove(cur_item) + else: + tree.selection_add(cur_item) + tree.selection_add(next_item) + tree.focus(next_item) + tree.see(next_item) + + def _on_treeview_shift_up(self, event): + tree: ttk.Treeview = event.widget + cur_item = tree.focus() + prev_item = tree.prev(cur_item) + if prev_item == '': + return 'break' + selection = tree.selection() + if prev_item in selection: + tree.selection_remove(cur_item) + else: + tree.selection_add(cur_item) + tree.selection_add(prev_item) + tree.focus(prev_item) + tree.see(prev_item) + + def _on_treeview_left_click(self, event): + tree = event.widget + if tree.identify_row(event.y) == '': + tree.selection_set(tuple()) + + def popupmenus(self, event): + w = event.widget + self.popupMenu = tk.Menu(self.mainwin, tearoff=False) + self.popupMenu.add_command( + label="Select all", accelerator="Ctrl+A", command=lambda: w.event_generate("")) + self.popupMenu.add_separator() + self.popupMenu.add_command( + label="Cut", accelerator="Ctrl+X", command=lambda: w.event_generate("")) + self.popupMenu.add_command( + label="Copy", accelerator="Ctrl+C", command=lambda: w.event_generate("")) + self.popupMenu.add_command( + label="Paste", accelerator="Ctrl+V", command=lambda: w.event_generate("")) + self.popupMenu.tk.call("tk_popup", self.popupMenu, + event.x_root, event.y_root) + + def onClose(self, event=None): + self.toplevel.quit() + self.toplevel.destroy() + + def onArrangeModeChanged(self): + self.builder.get_object('arrangeGroupPrec')['state'] = 'normal' if (self.arrangeMode.get() == 'instruments') else 'disabled' + + def applyTool(self): + get_object = self.builder.get_object + get_object('applyBtn')['state'] = 'disabled' + fileTable = self.fileTable + changedSongData = {} + selectedIndexes = [fileTable.index(item) + for item in fileTable.selection()] + selectionLen = len(selectedIndexes) + outputVersion = -1 + + if (formatComboIndex := get_object('formatCombo').current()) > 0: + outputVersion = (NBS_VERSION + 1) - formatComboIndex + + async def work(dialog: ProgressDialog = None): + print("async work() start") + try: + notebook : ttk.Notebook = get_object('headerNotebook') + headerModifiable = notebook.index(notebook.select()) == 1 + for i, index in enumerate(selectedIndexes): + dialog.totalProgress.set(i) + fileName = os.path.split(self.filePaths[i])[1] + dialog.currentText.set("Current file: {}".format(fileName)) + dialog.totalText.set("Processing {} / {} files".format(i+1, selectionLen)) + dialog.currentProgress.set(0) + songData: NbsSong = deepcopy(self.songsData[index]) + dialog.setCurrentPercentage(randint(20, 25)) + await asyncio.sleep(0.001) + dialog.currentMax = len(songData.notes)*2 + length = songData.header['length'] + maxLayer = songData.maxLayer + + if headerModifiable: + self.modifyHeaders(songData.header) + + if outputVersion > -1: + songData.header['file_version'] = outputVersion + + if self.flipHorizontallyCheckVar.get() or self.flipVerticallyCheckVar.get(): + for note in songData.notes: + if self.flipHorizontallyCheckVar.get(): + note['tick'] = length - note['tick'] + if self.flipVerticallyCheckVar.get(): + note['layer'] = maxLayer - note['layer'] + dialog.currentProgress.set(dialog.currentProgress.get()+1) + songData.sortNotes() + + if self.arrangeMode.get() == 'collapse': + self.collapseNotes(songData.notes) + elif self.arrangeMode.get() == 'instruments': + compactNotes(songData, self.groupPerc) + + dialog.setCurrentPercentage(randint(75, 85)) + await asyncio.sleep(0.001) + songData.sortNotes() + changedSongData[index] = songData + await asyncio.sleep(0.001) + + for k, v in changedSongData.items(): + self.songsData[k] = v + dialog.totalProgress.set(i+1) + except asyncio.CancelledError: + raise + finally: + get_object('applyBtn')['state'] = 'normal' + + dialog = ProgressDialog(self.toplevel, self) + dialog.d.set_title("Applying tools to {} files".format(selectionLen)) + dialog.totalMax = selectionLen + dialog.run(work) + + def collapseNotes(self, notes) -> None: + layer = 0 + prevNote = {'layer': -1, 'tick': -1} + for note in notes: + if note['tick'] == prevNote['tick']: + layer += 1 + note['layer'] = layer + else: + layer = 0 + note['layer'] = layer + prevNote = note + + def modifyHeaders(self, header): + def setAttrFromStrVar(key: str, value: str): + if value != '' and value.isdigit(): + try: + setattr(header, key, int(value)) + except: + print(f'Non-integer value: {value}') + get_object = self.builder.get_object + if not 'alternate' in get_object('headerAutosaveCheck').state(): + autoSave = self.headerAutosave.get() + header.auto_save = autoSave + if autoSave: + setAttrFromStrVar('auto_save_time', self.headerAutosaveInterval.get()) + + setAttrFromStrVar('minutes_spent', self.headerMinuteSpent.get()) + setAttrFromStrVar('left_clicks', self.headerLeftClicks.get()) + setAttrFromStrVar('right_clicks', self.headerRightClicks.get()) + setAttrFromStrVar('block_added', self.headerBlockAdded.get()) + setAttrFromStrVar('block_removed', self.headerBlockRemoved.get()) + + if not 'alternate' in get_object('headerLoopCheck').state(): + loop = self.headerLoop.get() + header.loop = loop + if loop: + setAttrFromStrVar('loop_max', self.headerLoopCount.get()) + setAttrFromStrVar('loop_start', self.headerLoopStart.get()) + + +class DatapackExportDialog: + def __init__(self, master, parent): + self.master = master + self.parent = parent + + self.builder = builder = pygubu.Builder() + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/datapackexportdialog.ui')) + + self.d: Dialog = builder.get_object('dialog', master) + + button = builder.get_object('exportBtn') + button.configure(command=self.export) + if len(parent.fileTable.selection()) == 0: + button["state"] = "disabled" + + def wnbsIDVaildate(P): + isVaild = bool(re.match("^(\d|\w|[-_.])+$", P)) + button["state"] = "normal" if isVaild and ( + 14 >= len(P) > 0) else "disabled" + return isVaild + + self.entry = entry = builder.get_object('IDEntry') + vcmd = (master.register(wnbsIDVaildate), '%P') + entry.configure(validatecommand=vcmd) + + builder.connect_callbacks(self) + + def run(self): + self.d.run() + + def export(self, _=None): + self.d.close() + fileTable = self.parent.fileTable + index = fileTable.index(fileTable.selection()[0]) + + path = askdirectory(title="Select folder to save") + if path == '': + return + exportDatapack(self.parent.songsData[index], os.path.join( + path, self.entry.get()), self.entry.get(), 'wnbs') + + +class MidiExportDialog: + def __init__(self, master, parent): + self.master = master + self.parent = parent + + self.builder = builder = pygubu.Builder() + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/midiexportdialog.ui')) + + self.d: Dialog = builder.get_object('dialog', master) + builder.get_object('pathChooser').bind('<>', self.pathChanged) + + builder.connect_callbacks(self) + builder.import_variables(self) + + self.exportModeChanged() + + def run(self): + self.d.run() + + def exportModeChanged(self): + self.isFolderMode = self.exportMode.get() == 'folder' + self.builder.get_object('pathChooser')['state'] = 'normal' if self.isFolderMode else 'disabled' + self.pathChanged() + + def pathChanged(self, _=None): + self.builder.get_object('exportBtn')['state'] = 'normal' if (not self.isFolderMode) or (self.exportPath.get() != '') else 'disabled' + + def export(self, _=None): + path = os.path + fileTable = self.parent.fileTable + indexes = [fileTable.index(i) for i in fileTable.selection()] + + if self.isFolderMode: + self.pathChanged() + if self.exportPath.get() != '': + Path(self.exportPath.get()).mkdir(parents=True, exist_ok=True) + else: + return + + async def work(dialog: ProgressDialog = None): + try: + songsData = self.parent.songsData + filePaths = self.parent.filePaths + for i in indexes: + dialog.totalProgress.set(i) + dialog.totalText.set("Exporting {} / {} files".format(i+1, len(indexes))) + dialog.currentProgress.set(0) + origPath = filePaths[i] + baseName = path.basename(origPath) + if baseName.endswith('.nbs'): + baseName = baseName[:-4] + baseName += '.mid' + + filePath = '' + if not self.isFolderMode: + filePath = path.join(path.dirname(origPath), baseName) + else: + filePath = path.join(self.exportPath.get(), baseName) + try: + dialog.currentText.set("Current file: {}".format(filePath)) + songData = deepcopy(songsData[i]) + compactNotes(songData, True) + dialog.currentProgress.set(10) # 10% + await nbs2midi(songData, filePath, dialog) + except Exception as e: + showerror("Exporting file error", 'Cannot export file "{}"\n{}: {}'.format(filePath, e.__class__.__name__, e)) + print(traceback.format_exc()) + dialog.totalProgress.set(dialog.currentMax) + except asyncio.CancelledError: + raise + self.d.toplevel.after(1, self.d.destroy) + + dialog = ProgressDialog(self.d.toplevel, self) + dialog.d.bind('<>', lambda _: self.d.destroy()) + dialog.d.set_title("Exporting {} files to MIDI".format(len(indexes))) + dialog.totalMax = len(indexes) + dialog.run(work) + + +class ProgressDialog: + def __init__(self, master, parent): + self.master = master + self.parent = parent + self.work = None + + self.builder = builder = pygubu.Builder() + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/progressdialog.ui')) + + self.d: Dialog = builder.get_object('dialog1', master) + self.d.toplevel.protocol('WM_DELETE_WINDOW', self.onCancel) + + builder.connect_callbacks(self) + builder.import_variables(self) + + @property + def currentMax(self) -> int: + return self.builder.get_object('currentProgressBar')['maximum'] + + @currentMax.setter + def currentMax(self, value: int) -> None: + self.builder.get_object('currentProgressBar')['maximum'] = value + + @property + def totalMax(self) -> int: + return self.builder.get_object('totalProgressBar')['maximum'] + + @totalMax.setter + def totalMax(self, value: int) -> None: + self.builder.get_object('totalProgressBar')['maximum'] = value + + def run(self, func=None): + self.builder.get_object('dialog1', self.master).run() + if asyncio.iscoroutinefunction(func): + self.work = func + self.d.toplevel.after(0, self.startWork) + + def startWork(self) -> None: + if self.totalProgress.get() >= self.totalMax: + self.d.destroy() + return + asyncio.run(self.updateProgress()) + print("startWork() about to after") + self.d.toplevel.after(0, self.startWork) + + async def updateProgress(self) -> None: + print("async updateProgressDialog() start") + self.task = asyncio.create_task(self.work(dialog=self)) + while True: # wait the async task finish + done, pending = await asyncio.wait({self.task}, timeout=0) + self.d.toplevel.update() + if self.task in done: + await self.task + break + + def setCurrentPercentage(self, value: int) -> None: + self.currentProgress.set(round(self.currentMax * value / 100)) + + def onCancel(self) -> None: + try: + allTasks = asyncio.all_tasks() + for task in allTasks: + task.cancel() + except RuntimeError: # if you have cancel the task it will raise RuntimeError + pass + self.d.destroy() + + +def parseFilePaths(string: str) -> list: + strLen = len(string) + ret = [] + filePath = '' + isQuoted = False + for i, char in enumerate(string): + i: int + char: str + if char == '(': + isQuoted = True + elif char == ')': + isQuoted = False + elif i+1 == strLen and filePath != '': + ret.append(filePath) + elif char.isspace(): + if isQuoted: + filePath += char + elif filePath != '': + ret.append(filePath) + filePath = '' + else: + filePath += char + return tuple(ret) + + +class MuseScoreImportDialog: + def __init__(self, master, parent): + self.master = master + self.parent = parent + + self.builder = builder = pygubu.Builder() + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/musescoreimportdialog.ui')) + + self.d: Dialog = builder.get_object('dialog', master) + + builder.connect_callbacks(self) + builder.import_variables(self) + + self.autoExpand.set(True) + self.filePaths.trace_add("write", self.pathChanged) + + def run(self): + self.d.run() + + def browse(self): + types = (("MuseScore files", ('*.mscz', '*.mscx')), ('All files', '*'),) + paths = askopenfilenames(filetypes=types) + + self.filePaths.set(paths) + + def autoExpandChanged(self): + self.builder.get_object('expandScale')['state'] = 'disabled' if self.autoExpand.get() else 'normal' + + def pathChanged(self, *args): + self.builder.get_object('importBtn')['state'] = 'normal' if (self.filePaths.get() != '') or (self.exportPath.get() != '') else 'disabled' + + def onImport(self, _=None): + fileTable = self.parent.fileTable + + if not self.autoExpand.get(): + self.pathChanged() + if self.filePaths.get() == '': + self.pathChanged() + return + + paths = literal_eval(self.filePaths.get()) + if isinstance(paths, str): + paths = parseFilePaths(paths) + fileCount = len(paths) + + async def work(dialog: ProgressDialog = None): + try: + songsData: list = self.parent.songsData + filePaths: list = self.parent.filePaths + for i, filePath in enumerate(paths): + try: + dialog.totalProgress.set(i) + dialog.totalText.set("Importing {} / {} files".format(i+1, fileCount)) + dialog.currentProgress.set(0) + dialog.currentText.set("Current file: {}".format(filePath)) + task = asyncio.create_task(musescore2nbs(filePath, self.expandMult.get(), self.autoExpand.get(), dialog)) + while True: + done, pending = await asyncio.wait({task}, timeout=0) + if task in done: + songData = task.result() + await task + break + if not songData: + raise Exception("The file {} cannot be read as a vaild XML file.".format(filePath)) + dialog.currentProgress.set(80) + await asyncio.sleep(0.001) + songsData.append(songData) + filePaths.append('') + self.parent.addFileInfo('', songData) + await asyncio.sleep(0.001) + except asyncio.CancelledError: + raise + except Exception as e: + showerror("Importing file error", 'Cannot import file "{}"\n{}: {}'.format(filePath, e.__class__.__name__, e)) + print(traceback.format_exc()) + continue + dialog.totalProgress.set(dialog.currentMax) + except asyncio.CancelledError: + raise + # self.d.toplevel.after(1, self.d.destroy) + + dialog = ProgressDialog(self.d.toplevel, self) + # dialog.d.bind('<>', lambda _: self.d.destroy()) + dialog.d.set_title("Importing {} MuseScore files".format(fileCount)) + dialog.totalMax = fileCount + dialog.run(work) + + +class AboutDialog: + def __init__(self, master, parent): + self.master = master + self.parent = parent + + self.builder = builder = pygubu.Builder() + builder.add_resource_path(resource_path()) + builder.add_from_file(resource_path('ui/aboutdialog.ui')) + + self.d: Dialog = builder.get_object('dialog', master) + builder.connect_callbacks(self) + self.d.run() + + def github(self): + webbrowser.open_new_tab("https://github.com/IoeCmcomc/NBSTool") + class FlexCheckbutton(tk.Checkbutton): - def __init__(self, *args, **kwargs): - okwargs = dict(kwargs) - if 'multiline' in kwargs: - self.multiline = kwargs['multiline'] - del okwargs['multiline'] - else: - self.multiline = True - - tk.Checkbutton.__init__(self, *args, **okwargs) - - self.text = kwargs['text'] - if 'anchor' in kwargs: - self.anchor = kwargs['anchor'] - else: - self.anchor = 'w' - self['anchor'] = self.anchor - - if 'justify' in kwargs: - self.justify = kwargs['justify'] - else: self.justify = 'left' - self['justify'] = self.justify - - if self.multiline: - self.bind("", lambda event: self.configure(width=event.width-10, justify=self.justify, anchor=self.anchor, wraplength=event.width-20, text=self.text+' '*999) ) -#Currently unused -class SquareButton(tk.Button): - def __init__(self, *args, **kwargs): - self.blankImg = tk.PhotoImage() - - if "size" in kwargs: - #print(kwargs['size']) - self.size = kwargs['size'] - del kwargs['size'] - else: - self.size = 30 - - pprint(kwargs) - - tk.Button.__init__(self, *args, **kwargs) - - self.configure(image=self.blankImg, font=("Arial", self.size-3), width=self.size, height=self.size, compound=tk.CENTER) - -class StackingWidget(tk.Frame): - def __init__(self, parent, **kwargs): - super().__init__(parent, **kwargs) - self._frames = {} - self._i = 0 - #self._shown = None - def __getitem__(self, key): - if key in self._frames: return self._frames[key][0] - super().__getitem__(key) - def append(self, frame, key=None): - if isinstance(frame, (tk.Widget, ttk.Widget)): - if not key: - key = self._i - self._i += 1 - self._frames[key] = [frame, None] - #self._shown = key - #return self._frames[name] - def switch(self, key): - for k, (w, o) in self._frames.items(): - if k == key: - if o: w.pack(**o) - else: w.pack() - else: w.pack_forget() - def pack(self, key=None, **opts): - if key: - self._frames[key][1] = opts - #self._frames[key][0].pack(**opts) - else: super().pack(**opts) - if len(self._frames) == 1: - self.switch(key) - - -def WindowGeo(obj, parent, width, height, mwidth=None, mheight=None): - ScreenWidth = root.winfo_screenwidth() - ScreenHeight = root.winfo_screenheight() - - WindowWidth = width or obj.winfo_reqwidth() - WindowHeight = height or obj.winfo_reqheight() - - WinPosX = int(ScreenWidth / 2 - WindowWidth / 2) - WinPosY = int(ScreenHeight / 2.3 - WindowHeight / 2) - - obj.minsize(mwidth or obj.winfo_width(), mheight or obj.winfo_height()) - obj.geometry("{}x{}+{}+{}".format(WindowWidth, WindowHeight, WinPosX, WinPosY)) - #obj.update() - obj.update_idletasks() - - -def compactNotes(data, sepInst=1, groupPerc=1): - sepInst, groupPerc = bool(sepInst), bool(groupPerc) - r = data - PrevNote = {'layer':-1, 'tick':-1} - if sepInst: - OuterLayer = 0 - iter = r['usedInsts'][0] - if not groupPerc: iter += r['usedInsts'][1] - for inst in iter: - #print('Instrument: {}'.format(inst)) - InnerLayer = LocalLayer = c = 0 - #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) - for note in r['notes']: - if note['inst'] == inst: - c += 1 - if note['tick'] == PrevNote['tick']: - LocalLayer += 1 - InnerLayer = max(InnerLayer, LocalLayer) - note['layer'] = LocalLayer + OuterLayer - else: - LocalLayer = 0 - note['layer'] = LocalLayer + OuterLayer - PrevNote = note - #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) - OuterLayer += InnerLayer + 1 - #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) - if groupPerc: - InnerLayer = LocalLayer = c = 0 - for note in r['notes']: - if note['inst'] in r['usedInsts'][1]: - c += 1 - if note['tick'] == PrevNote['tick']: - LocalLayer += 1 - InnerLayer = max(InnerLayer, LocalLayer) - note['layer'] = LocalLayer + OuterLayer - else: - LocalLayer = 0 - note['layer'] = LocalLayer + OuterLayer - PrevNote = note - OuterLayer += InnerLayer + 1 - r['maxLayer'] = OuterLayer - 1 - else: - layer = 0 - for note in r['notes']: - if note['tick'] == PrevNote['tick']: - layer += 1 - note['layer'] = layer - else: - layer = 0 - note['layer'] = layer - PrevNote = note - return r - -def exportMIDI(cls, path, byLayer=False): - data = copy.deepcopy(cls.inputFileData) - byLayer = bool(byLayer) - - if not byLayer: - data = DataPostprocess(data) - data = compactNotes(data) - - UniqInstEachLayer = {} - for note in data['notes']: - if note['layer'] not in UniqInstEachLayer: - if note['isPerc']: UniqInstEachLayer[note['layer']] = -1 - else: UniqInstEachLayer[note['layer']] = note['inst'] - else: - if not note['isPerc']: note['inst'] = UniqInstEachLayer[note['layer']] - - lenTrack = data['maxLayer'] + 1 - for i in range(lenTrack): - if i not in UniqInstEachLayer: UniqInstEachLayer[i] = 0 - - main_score = m21s.Score() - - percussions = ( - #(percussion_key, instrument, key) - (35, 2, 64), - (36, 2, 60), - (37, 4, 60), - (38, 3, 62), - #(39, 4, 60), - (40, 3, 58), - #(41, 2, 60), - (42, 3, 76), - (43, 2, 67), - #(44, 3, 76), - (45, 2, 69), - (46, 2, 72), - (47, 2, 74), - (48, 2, 77), - (49, 2, 71), - (50, 3, 77), - (51, 3, 78), - (52, 3, 62), - (53, 3, 67), - (54, 3, 72), - (55, 3, 73), - (56, 4, 55), - #(57, 3, 67), - (58, 4, 56), - #(59, 3, 67), - (60, 4, 63), - (61, 4, 57), - (62, 4, 62), - (63, 2, 76), - (64, 3, 69), - #(65, 3, 67), - #(66, 3, 62), - #(67, 4, 62), - (68, 4, 58), - (69, 4, 74), - (70, 4, 77), - (73, 3, 71), - (74, 4, 65), - (75, 4, 72), - (76, 4, 64), - (77, 4, 59), - (80, 4, 71), - (81, 4, 76), - (82, 3, 78) - ) - - instrument_codes = {-1:m21i.Percussion, - 0: m21i.Harp, - 1: m21i.AcousticBass, - 5: m21i.Guitar, - 6: m21i.Flute, - 7: m21i.Handbells, - 8: m21i.ChurchBells, - 9: m21i.Xylophone, - 10: m21i.Xylophone, - 11: m21i.Piano, - 12: m21i.Piano, - 13: m21i.Piano, - 14: m21i.Banjo, - 15: m21i.Piano, - } - - timeSign = data['headers']['time_sign'] - time = 0 - tempo = data['headers']['tempo'] * 60 / timeSign - volume = 127 - - c = 0 - for i in range(lenTrack): - staff = m21s.Part() - staff.append(m21.tempo.MetronomeMark(number=tempo)) #Tempo - staff.timeSignature = m21.meter.TimeSignature('{}/4'.format(timeSign)) #Time signature - staff.clef = m21.clef.TrebleClef() #Clef - try: - staff.append(instrument_codes[UniqInstEachLayer[i]]()) - except KeyError: - staff.append(m21i.Piano()) - main_score.append(staff) - - for i, note in enumerate(data['notes']): - time = note['tick'] / timeSign - pitch = note['key'] + 21 - duration = 2 if note['duration'] == 0 else note['duration'] / timeSign - track = note['layer'] - - if note['isPerc']: - for a, b, c in percussions: - if c == pitch and b == note['inst']: pitch = a - - if byLayer: - volume = int(data['layers'][note['layer']]['volume'] / 100 * 127) - - #print("track: {}, channel: {}, pitch: {}, time: {}, duration: {}, volume: {}".format(track, channel, pitch, time, duration, volume)) - - a_note = m21.note.Note() - a_note.pitch.midi = pitch #Pitch - a_note.duration.quarterLength = 1 / 4 #Duration - a_note.volume = volume - main_score[track].append(a_note) - a_note.offset = time - - cls.UpdateProgBar(10 + int( note['tick'] / (data['headers']['length']+1) * 80), 0) - - mt = m21.metadata.Metadata() - mt.title = mt.popularTitle = 'Title' - mt.composer = 'Composer' - main_score.insert(0, mt) - - #fn = main_score.write("midi", path) - - mid = m21.midi.translate.streamToMidiFile(main_score) - - if data['hasPerc']: - for i in range(lenTrack): - if UniqInstEachLayer[i] == -1: - for el in mid.tracks[i].events: - el.channel = 10 - - cls.UpdateProgBar(95) - - mid.open(path, 'wb') - mid.write() - mid.close() - - #exper_s = m21.midi.translate.midiFileToStream(mid) - #exper_s.write("midi", path+'_test.mid') - -def exportMusic(cls, path, ext): - start = time() - noteSounds = cls.noteSounds - notes = cls.inputFileData['notes'] - headers = cls.inputFileData['headers'] - tempo = headers['tempo'] - length = headers['length']+1 - layers = cls.inputFileData['layers'] - tickIndexes = cls.inputFileData['indexByTick'] - toUnsigned = lambda x: 256 + x if x < 0 else x - tickSoundLength = max(len(x['obj']) for x in noteSounds) - music = AudioSegment.silent(duration=int(length / tempo * 1000 + 500)) - lastInst = -1 - - for currtick in range(length): - currNotes = tickIndexes[currtick][1] - - for n, i in enumerate(currNotes): - note = notes[i] - currLayer = layers[note['layer']] - inst = note['inst'] - - if inst != lastInst: - currNoteSound = noteSounds[inst] - noteSoundObj = currNoteSound['obj'] - - ANoteSound = noteSoundObj._spawn(noteSoundObj.raw_data, overrides={'frame_rate': int(noteSoundObj.frame_rate * (2.0 ** ((note['key'] - currNoteSound['pitch']) / 12))) }).set_frame_rate(44100) - - if 0 < currLayer['volume'] < 100: ANoteSound = ANoteSound.apply_gain(noteSoundObj.dBFS - noteSoundObj.dBFS * (currLayer['volume'] / 100)) + 3 - - if currLayer['stereo'] != 100: ANoteSound = ANoteSound.pan((toUnsigned(currLayer['stereo']) - 100) / 100) - - if len(currNotes) == 1: tickSound = ANoteSound - elif n == 0: tickSound = ANoteSound + AudioSegment.silent(duration=tickSoundLength - len(ANoteSound)) - else: tickSound = tickSound.overlay(ANoteSound) - - lastInst = note['inst'] - - if len(currNotes) > 0: music = music.overlay(tickSound.set_frame_rate(44100), position=int(note['tick'] / tempo * 1000)) - - cls.UpdateProgBar(10 + int(note['tick'] / length * 80), 0) - #print('Processing {}/{} tick. Time: {:3f}. Done in {:3f} seconds.'.format(note['tick'], length, time() - start, time() - lstart)) - - - meta = {'album': '', - 'artist': headers['author'], - 'comment': headers['description'], - 'date': str(date.today().year), - 'genre': 'Minecraft note block', - 'title': headers['name'], - 'track': ''} - - #meta = {'genre': 'Minecraft note block'} - - cls.UpdateProgBar(95) - music.export(path, format=ext, tags=meta) - print("Exported in {:3f} seconds.".format(time() - start)) - - -def exportDatapack(cls, path, mode='none'): - def writejson(path, jsout): - with open(path, 'w') as f: - json.dump(jsout, f, ensure_ascii=False) - def writemcfunction(path, text): - with open(path, 'w') as f: - f.write(text) - - def makeFolderTree(inp, a=[]): - print(a) - if isinstance(inp, (tuple, set)): - for el in inp: - makeFolderTree(el, copy.copy(a)) - elif isinstance(inp, dict): - for k, v in inp.items(): - makeFolderTree(v, a + [k]) - elif isinstance(inp, str): - p = os.path.join(*a, inp) - #print(p) - os.makedirs(p, exist_ok=True) - else: - return - - def wnbs(): - scoreObj = "wnbs_" + bname[:7] - speed = int(min(data['headers']['tempo'] * 4, 120)) - length = data['headers']['length'] - - # os.path.exists() - - makeFolderTree( - {path:{ - 'data':{ - bname:{ - 'functions':{ - 'notes', - 'tree', - }, - }, - 'minecraft':{ - 'tags':'functions', - }, - }, - }, - } - ) - - writejson(os.path.join(path, 'pack.mcmeta'), {"pack":{"description":"Note block song made with NBSTool.", "pack_format":1}} ) - writejson(os.path.join(path, 'data', 'minecraft', 'tags', 'functions', 'load.json'), jsout = {"values":["{}:load".format(bname)]} ) - writejson(os.path.join(path, 'data', 'minecraft', 'tags', 'functions', 'tick.json'), jsout = {"values":["{}:tick".format(bname)]} ) - - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'load.mcfunction'), -"""scoreboard objectives add {0} dummy + def __init__(self, *args, **kwargs): + okwargs = dict(kwargs) + if 'multiline' in kwargs: + self.multiline = kwargs['multiline'] + del okwargs['multiline'] + else: + self.multiline = True + + tk.Checkbutton.__init__(self, *args, **okwargs) + + self.text = kwargs['text'] + if 'anchor' in kwargs: + self.anchor = kwargs['anchor'] + else: + self.anchor = 'w' + self['anchor'] = self.anchor + + if 'justify' in kwargs: + self.justify = kwargs['justify'] + else: + self.justify = 'left' + self['justify'] = self.justify + + if self.multiline: + self.bind("", lambda event: self.configure(width=event.width-10, + justify=self.justify, anchor=self.anchor, wraplength=event.width-20, text=self.text+' '*999)) + +def centerToplevel(obj, width=None, height=None, mwidth=None, mheight=None): + # Credit: https://stackoverflow.com/questions/3129322/how-do-i-get-monitor-resolution-in-python/56913005#56913005 + def get_curr_screen_size(): + """ + Workaround to get the size of the current screen in a multi-screen setup. + + Returns: + Size(Tuple): (width, height) + """ + root = tk.Tk() + root.update_idletasks() + root.attributes('-fullscreen', True) + root.state('iconic') + size = (root.winfo_width(), root.winfo_height(),) + root.destroy() + return size + + ScreenWidth, ScreenHeight = get_curr_screen_size() + + WindowWidth = width or obj.winfo_width() + WindowHeight = height or obj.winfo_height() + + WinPosX = int(ScreenWidth / 2 - WindowWidth / 2) + WinPosY = int(ScreenHeight / 2.3 - WindowHeight / 2) + + obj.minsize(mwidth or obj.winfo_width(), mheight or obj.winfo_height()) + obj.geometry("{}x{}+{}+{}".format(WindowWidth, + WindowHeight, WinPosX, WinPosY)) + # obj.update() + obj.update_idletasks() + + +def compactNotes(data, groupPerc=1) -> None: + groupPerc = bool(groupPerc) + prevNote = {'layer': -1, 'tick': -1} + outerLayer = 0 + it = data['usedInsts'][0] + if not groupPerc: + it += data['usedInsts'][1] + for inst in it: + #print('Instrument: {}'.format(inst)) + innerLayer = localLayer = c = 0 + #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) + for note in data['notes']: + if note['inst'] == inst: + c += 1 + if note['tick'] == prevNote['tick']: + localLayer += 1 + innerLayer = max(innerLayer, localLayer) + note['layer'] = localLayer + outerLayer + else: + localLayer = 0 + note['layer'] = localLayer + outerLayer + prevNote = note + #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) + outerLayer += innerLayer + 1 + #print('OuterLayer: {}; Innerlayer: {}; LocalLayer: {}; c: {}'.format(OuterLayer, InnerLayer, LocalLayer, c)) + if groupPerc: + innerLayer = localLayer = c = 0 + for note in data['notes']: + if note['inst'] in data['usedInsts'][1]: + c += 1 + if note['tick'] == prevNote['tick']: + localLayer += 1 + innerLayer = max(innerLayer, localLayer) + note['layer'] = localLayer + outerLayer + else: + localLayer = 0 + note['layer'] = localLayer + outerLayer + prevNote = note + outerLayer += innerLayer + 1 + data['maxLayer'] = outerLayer - 1 + +def exportDatapack(data, path, bname, mode=None, master=None): + def writejson(path, jsout): + with open(path, 'w') as f: + json.dump(jsout, f, ensure_ascii=False) + + def writemcfunction(path, text): + with open(path, 'w') as f: + f.write(text) + + def makeFolderTree(inp, a=[]): + if isinstance(inp, (tuple, set)): + for el in inp: + makeFolderTree(el, copy.copy(a)) + elif isinstance(inp, dict): + for k, v in inp.items(): + makeFolderTree(v, a + [k]) + elif isinstance(inp, str): + p = os.path.join(*a, inp) + os.makedirs(p, exist_ok=True) + else: + return + + path = os.path.join(*os.path.normpath(path).split()) + bname = os.path.basename(path) + + data.correctData() + compactNotes(data, groupPerc=False) + + noteSounds = vaniNoteSounds + data['customInsts'] + + instLayers = {} + for note in data['notes']: + if note['inst'] not in instLayers: + instLayers[note['inst']] = [note['layer']] + elif note['layer'] not in instLayers[note['inst']]: + instLayers[note['inst']].append(note['layer']) + + scoreObj = bname[:13] + speed = int(min(data['header']['tempo'] * 4, 120)) + length = data['header']['length'] + + makeFolderTree( + {path: { + 'data': { + bname: { + 'functions': { + 'notes', + 'tree', + }, + }, + 'minecraft': { + 'tags': 'functions', + }, + }, + }, + } + ) + + writejson(os.path.join(path, 'pack.mcmeta'), {"pack": { + "description": "Note block song datapack made with NBSTool.", "pack_format": 6}}) + writejson(os.path.join(path, 'data', 'minecraft', 'tags', 'functions', + 'load.json'), jsout={"values": ["{}:load".format(bname)]}) + writejson(os.path.join(path, 'data', 'minecraft', 'tags', 'functions', + 'tick.json'), jsout={"values": ["{}:tick".format(bname)]}) + + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'load.mcfunction'), + """scoreboard objectives add {0} dummy scoreboard objectives add {0}_t dummy -scoreboard players set speed {0} {1}""".format(scoreObj, speed) ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'tick.mcfunction'), -"""execute as @a[tag={0}] run scoreboard players operation @s {0} += speed {0} +scoreboard players set speed {0} {1}""".format(scoreObj, speed)) + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'tick.mcfunction'), + """execute as @a[tag={0}] run scoreboard players operation @s {0} += speed {0} execute as @a[tag={0}] run function {1}:tree/0_{2} -execute as @e[type=armor_stand, tag=WNBS_Marker] at @s unless block ~ ~-1 ~ minecraft:note_block run kill @s""".format(scoreObj, bname, 2**(floor(log2(length))+1)-1) ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'play.mcfunction'), -"""tag @s add {0} +execute as @e[type=armor_stand, tag=WNBS_Marker] at @s unless block ~ ~-1 ~ minecraft:note_block run kill @s""".format(scoreObj, bname, 2**(floor(log2(length))+1)-1)) + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'play.mcfunction'), + """tag @s add {0} scoreboard players set @s {0}_t -1 -""".format(scoreObj) ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'pause.mcfunction'), - "tag @s remove {}".format(scoreObj) ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'stop.mcfunction'), -"""tag @s remove {0} +""".format(scoreObj)) + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'pause.mcfunction'), + "tag @s remove {}".format(scoreObj)) + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'stop.mcfunction'), + """tag @s remove {0} scoreboard players reset @s {0} -scoreboard players reset @s {0}_t""".format(scoreObj) ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'remove.mcfunction'), -"""scoreboard objectives remove {0} -scoreboard objectives remove {0}_t""".format(scoreObj) ) - - text = '' - for k, v in instLayers.items(): - for i in range(len(v)): - text += 'execute run give @s minecraft:armor_stand{{display: {{Name: "{{\\"text\\":\\"{}\\"}}" }}, EntityTag: {{Marker: 1b, NoGravity:1b, Invisible: 1b, Tags: ["WNBS_Marker"], CustomName: "{{\\"text\\":\\"{}\\"}}" }} }}\n'.format( - "{}-{}".format(noteSounds[k]['name'], i), "{}-{}".format(k, i) - ) - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'give.mcfunction'), text) - - colNotes = { tick: [x for x in data['notes'] if x['tick'] == tick] for tick in range(length) } - #pprint(colNotes) - print(len(colNotes)) - - for tick in range(length): - currNotes = colNotes[tick] - text = "" - #text = "say Calling function {}:notes/{}\n".format(bname, tick) - for note in currNotes: - text += \ -"""execute as @e[type=armor_stand, tag=WNBS_Marker, name=\"{inst}-{order}\"] at @s positioned ~ ~-1 ~ if block ~ ~ ~ minecraft:note_block[instrument={instname}] run setblock ~ ~ ~ minecraft:note_block[instrument={instname},note={key}] replace +scoreboard players reset @s {0}_t""".format(scoreObj)) + writemcfunction(os.path.join(path, 'data', bname, 'functions', 'uninstall.mcfunction'), + """scoreboard objectives remove {0} +scoreboard objectives remove {0}_t""".format(scoreObj)) + + text = '' + for k, v in instLayers.items(): + for i in range(len(v)): + text += 'execute run give @s minecraft:armor_stand{{display: {{Name: "{{\\"text\\":\\"{}\\"}}" }}, EntityTag: {{Marker: 1b, NoGravity:1b, Invisible: 1b, Tags: ["WNBS_Marker"], CustomName: "{{\\"text\\":\\"{}\\"}}" }} }}\n'.format( + "{}-{}".format(noteSounds[k]['name'], + i), "{}-{}".format(k, i) + ) + writemcfunction(os.path.join(path, 'data', bname, + 'functions', 'give.mcfunction'), text) + + tick = 0 + colNotes = {tick: []} + for note in data['notes']: + colNotes[tick].append(note) + while note['tick'] != tick: + tick += 1 + colNotes[tick] = [] + + for tick in range(length): + text = "" + if tick in colNotes: + currNotes = colNotes[tick] + for note in currNotes: + text += \ + """execute as @e[type=armor_stand, tag=WNBS_Marker, name=\"{inst}-{order}\"] at @s positioned ~ ~-1 ~ if block ~ ~ ~ minecraft:note_block[instrument={instname}] run setblock ~ ~ ~ minecraft:note_block[instrument={instname},note={key}] replace execute as @e[type=armor_stand, tag=WNBS_Marker, name=\"{inst}-{order}\"] at @s positioned ~ ~-1 ~ if block ~ ~ ~ minecraft:note_block[instrument={instname}] run fill ^ ^ ^-1 ^ ^ ^-1 minecraft:redstone_block replace minecraft:air execute as @e[type=armor_stand, tag=WNBS_Marker, name=\"{inst}-{order}\"] at @s positioned ~ ~-1 ~ if block ~ ~ ~ minecraft:note_block[instrument={instname}] run fill ^ ^ ^-1 ^ ^ ^-1 minecraft:air replace minecraft:redstone_block -""".format( - obj=scoreObj, tick=tick, inst=note['inst'], order=instLayers[note['inst']].index(note['layer']), instname=noteSounds[note['inst']]['name'], key=max(33, min(57, note['key'])) - 33 - ) - if tick == length-1: text += "execute run function {}:stop".format(bname) - else: text += "scoreboard players set @s {}_t {}".format(scoreObj, tick) - if text != "": writemcfunction(os.path.join(path, 'data', bname, 'functions', 'notes', str(tick)+'.mcfunction'), text) - - steps = floor(log2(length)) + 1 - pow = 2**steps - for step in range(steps): - searchrange = floor(pow / (2**step)) - segments = floor(pow / searchrange) - for segment in range(segments): - text = "" - half = floor(searchrange / 2) - lower = searchrange * segment - - min1 = lower - max1 = lower + half - 1 - min2 = lower + half - max2 = lower + searchrange - 1 - - #print(str(step) + " " + str(segments) + " " + str(min1) + " " + str(max1) + " " + str(min2) + " " + string(max2)) - - if min1 <= length: - if step == steps-1: # Last step, play the tick - try: - if len(colNotes[min1]) > 0: text += "execute as @s[scores={{{0}={1}..{2}, {0}_t=..{3}}}] run function {4}:notes/{5}\n".format(scoreObj, min1*80, (max1+1)*80+40, min1-1, bname, min1) - except KeyError: break - if min2 <= length: - try: - if len(colNotes[min2]) > 0: text += "execute as @s[scores={{{0}={1}..{2}, {0}_t=..{3}}}] run function {4}:notes/{5}".format(scoreObj, min2*80, (max2+1)*80+40, min2-1, bname, min2) - except KeyError: break - else: # Don't play yet, refine the search - for i in range(min1, min(max1, length)+1): - try: - if len(colNotes[i]) > 0: - text += "execute as @s[scores={{{}={}..{}}}] run function {}:tree/{}_{}\n".format(scoreObj, min1*80, (max1+1)*80+40, bname, min1, max1) - break - except KeyError: break - for i in range(min2, min(max2, length)+1): - try: - if len(colNotes[i]) > 0: - text += "execute as @s[scores={{{}={}..{}}}] run function {}:tree/{}_{}".format(scoreObj, min2*80, (max2+2)*80+40, bname, min2, max2) - break - except KeyError: break - if text != "": - #text = "say Calling function {}:tree/{}_{}\n".format(bname, min1, max2) + text - writemcfunction(os.path.join(path, 'data', bname, 'functions', 'tree', '{}_{}.mcfunction'.format(min1, max2)), text) - else: break - - path = os.path.join(*os.path.normpath(path).split()) - bname = os.path.basename(path) - - data = DataPostprocess(cls.inputFileData) - data = compactNotes(data, groupPerc=False) - - noteSounds = cls.noteSounds - - instLayers = {} - for note in data['notes']: - if note['inst'] not in instLayers: - instLayers[note['inst']] = [note['layer']] - elif note['layer'] not in instLayers[note['inst']]: - instLayers[note['inst']].append(note['layer']) - #pprint(instLayers) - - locals()[mode]() - print("Done!") +""".format(obj=scoreObj, tick=tick, inst=note['inst'], order=instLayers[note['inst']].index(note['layer']), instname=noteSounds[note['inst']]['name'], key=max(33, min(57, note['key'])) - 33) + if tick < length-1: + text += "scoreboard players set @s {}_t {}".format( + scoreObj, tick) + else: + text += "execute run function {}:stop".format(bname) + if text != "": + writemcfunction(os.path.join( + path, 'data', bname, 'functions', 'notes', str(tick)+'.mcfunction'), text) + + steps = floor(log2(length)) + 1 + pow = 2**steps + for step in range(steps): + searchrange = floor(pow / (2**step)) + segments = floor(pow / searchrange) + for segment in range(segments): + text = "" + half = floor(searchrange / 2) + lower = searchrange * segment + + min1 = lower + max1 = lower + half - 1 + min2 = lower + half + max2 = lower + searchrange - 1 + + if min1 <= length: + if step == steps-1: # Last step, play the tick + try: + if len(colNotes[min1]) > 0: + text += "execute as @s[scores={{{0}={1}..{2}, {0}_t=..{3}}}] run function {4}:notes/{5}\n".format( + scoreObj, min1*80, (max1+1)*80+40, min1-1, bname, min1) + except KeyError: + break + if min2 <= length: + try: + if len(colNotes[min2]) > 0: + text += "execute as @s[scores={{{0}={1}..{2}, {0}_t=..{3}}}] run function {4}:notes/{5}".format( + scoreObj, min2*80, (max2+1)*80+40, min2-1, bname, min2) + except KeyError: + break + else: # Don't play yet, refine the search + for i in range(min1, min(max1, length)+1): + try: + if len(colNotes[i]) > 0: + text += "execute as @s[scores={{{}={}..{}}}] run function {}:tree/{}_{}\n".format( + scoreObj, min1*80, (max1+1)*80+40, bname, min1, max1) + break + except KeyError: + break + for i in range(min2, min(max2, length)+1): + try: + if len(colNotes[i]) > 0: + text += "execute as @s[scores={{{}={}..{}}}] run function {}:tree/{}_{}".format( + scoreObj, min2*80, (max2+2)*80+40, bname, min2, max2) + break + except KeyError: + break + if text != "": + writemcfunction(os.path.join( + path, 'data', bname, 'functions', 'tree', '{}_{}.mcfunction'.format(min1, max2)), text) + else: + break + if __name__ == "__main__": - - vaniNoteSounds = [ - {'filename': 'harp.ogg', 'name': 'harp'}, - {'filename': 'dbass.ogg', 'name': 'bass'}, - {'filename': 'bdrum.ogg', 'name': 'basedrum'}, - {'filename': 'sdrum.ogg', 'name': 'snare'}, - {'filename': 'click.ogg', 'name': 'hat'}, - {'filename': 'guitar.ogg', 'name': 'guitar'}, - {'filename': 'flute.ogg', 'name': 'flute'}, - {'filename': 'bell.ogg', 'name': 'bell'}, - {'filename': 'icechime.ogg', 'name': 'chime'}, - {'filename': 'xylobone.ogg', 'name': 'xylophone'}, - {'filename': 'iron_xylophone.ogg', 'name': 'iron_xylophone'}, - {'filename': 'cow_bell.ogg', 'name': 'cow_bell'}, - {'filename': 'didgeridoo.ogg', 'name': 'didgeridoo'}, - {'filename': 'bit.ogg', 'name': 'bit'}, - {'filename': 'banjo.ogg', 'name': 'banjo'}, - {'filename': 'pling.ogg', 'name': 'pling'} -] + app = MainWindow() - print('Importing sounds...') - vaniNoteSounds = [ {'name': item['name'], 'filepath': resource_path('sounds', item['filename']), 'obj': AudioSegment.from_ogg(resource_path('sounds', item['filename'])), 'pitch': 45} for item in vaniNoteSounds] + print(sys.argv) - print('Imported sounds.') - print('Creating root...') + if len(sys.argv) == 2: + app.addFiles(paths=[sys.argv[1], ]) - root = tk.Tk() - app = MainWindow(root) - print('Creating app...') - - print(sys.argv) - if len(sys.argv) == 2: app.OnBrowseFile(True, sys.argv[1]) + print('Ready') + app.mainwin.mainloop() - root.iconbitmap(resource_path("icon.ico")) - print('Ready') - root.mainloop() - - print("The app was closed.") \ No newline at end of file + print("The app was closed.") diff --git a/musescore2nbs.py b/musescore2nbs.py new file mode 100644 index 0000000..d0a1e4b --- /dev/null +++ b/musescore2nbs.py @@ -0,0 +1,505 @@ +# This file is a part of: +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# __________________________________________________________________________________ +# NBSTool is a tool to work with .nbs (Note Block Studio) files. +# Author: IoeCmcomc (https://github.com/IoeCmcomc) +# Programming language: Python +# License: MIT license +# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + + +from addict import Dict +from lxml import etree +import zipfile +from nbsio import NbsSong +from functools import lru_cache +from os.path import basename +from asyncio import sleep + +INST_INFO = ( + ('Acoustic Grand Piano', 0, 0, 'Grand Piano'), + ('Bright Acoustic Piano', 0, 0, 'Acoustic Piano'), + ('Electric Grand Piano', 13, 0, 'E. Grand Piano'), + ('Honky - tonk Piano', 0, 0, 'H.T. Piano'), + ('Electric Piano 1', 13, 0, 'E. Piano 1'), + ('Electric Piano 2', 13, 0, 'E. Piano 2'), + ('Harpsichord', 0, 1, ''), + ('Clavinet', 0, 0, ''), + ('Celesta', 11, -1, ''), + ('Glockenspiel', 11, 0, ''), + ('Music Box', 11, 0, ''), + ('Vibraphone', 11, 0, ''), + ('Marimba', 11, 0, ''), + ('Xylophone', 9, 0, ''), + ('Tubular Bells', 7, -1, 'T. Bells'), + ('Dulcimer', 7, 0, ''), + ('Drawbar Organ', 1, 1, 'D. Organ'), + ('Percussive Organ', 1, 1, 'P. Organ'), + ('Rock Organ', 0, 0, ''), + ('Church Organ', 0, 0, ''), + ('Reed Organ', 0, 0, ''), + ('Accordion', 0, 0, ''), + ('Harmonica', 0, 0, ''), + ('Tango Accordion', 0, 0, 'T. Accordion'), + ('Acoustic Guitar (nylon)', 5, 0, 'A.Guitar(nylon)'), + ('Acoustic Guitar (steel)', 5, 0, 'A.Guitar(steel)'), + ('Electric Guitar (jazz)', 5, 1, 'E.Guitar(jazz)'), + ('Electric Guitar (clean)', 5, 0, 'E.Guitar(clean)'), + ('Electric Guitar (muted)', -1, 0, 'E.Guitar(mute)'), + ('Overdriven Guitar', 5, -1, 'OD Guitar'), + ('Distortion Guitar', 5, -1, 'Dist. Guitar'), + ('Guitar Harmonics', 5, 0, 'Guitar H.'), + ('Acoustic Bass', 1, 1, 'A. Bass'), + ('Electric Bass (finger)', 1, 2, 'E.Bass (finger)'), + ('Electric Bass (pick)', 1, 2, 'E.Bass (pick)'), + ('Fretless Bass', 1, 2, ''), + ('Slap Bass 1', 1, 2, ''), + ('Slap Bass 2', 1, 2, ''), + ('Synth Bass 1', 1, 2, ''), + ('Synth Bass 2', 1, 2, ''), + ('Violin', 6, 0, ''), + ('Viola', 6, 0, ''), + ('Cello', 6, 0, ''), + ('Contrabass', 6, 0, ''), + ('Tremolo Strings', 0, 0, 'T. Strings'), + ('Pizzicato Strings', 0, 0, 'P. Strings'), + ('Orchestral Harp', 8, 0, 'O. Harp'), + ('Timpani', 3, 1, ''), + ('String Ensemble 1', 0, 0, 'String E. 1'), + ('String Ensemble 2', 0, 0, 'String E. 2'), + ('Synth Strings 1', 0, 0, 'S. Strings 1'), + ('Synth Strings 2', 0, 0, 'S. Strings 2'), + ('Choir Aahs', 0, 0, ''), + ('Voice Oohs', 0, 0, ''), + ('Synth Choir', 0, 0, ''), + ('Orchestra hit', 0, 0, 'O. Hit'), + ('Trumpet', 0, 0, ''), + ('Trombone', 0, 0, ''), + ('Tuba', 0, 0, ''), + ('Muted Trumpet', 0, 0, ''), + ('French Horn', 0, 0, ''), + ('Brass Section', 0, 0, ''), + ('Synth Brass 1', 1, 1, 'S. Brass 1'), + ('Synth Brass 2', 1, 1, 'S. Brass 2'), + ('Soprano Sax', 6, 0, ''), + ('Alto Sax', 6, 0, ''), + ('Tenor Sax', 6, 0, ''), + ('Baritone Sax', 6, 0, ''), + ('Oboe', 6, 0, ''), + ('English Horn', 6, 0, ''), + ('Bassoon', 6, -1, ''), + ('Clarinet', 6, 0, ''), + ('Piccolo', 6, -1, ''), + ('Flute', 6, -1, ''), + ('Recorder', 6, -1, ''), + ('Pan Flute', 6, -1, ''), + ('Blown Bottle', 6, -1, ''), + ('Shakuhachi', 6, -1, ''), + ('Whistle', 6, -1, ''), + ('Ocarina', 6, -1, ''), + ('Lead 1 (square)', 0, 0, 'L.1 (square)'), + ('Lead 2 (sawtooth)', 0, 0, 'L.2 (sawtooth)'), + ('Lead 3 (calliope)', 0, 0, 'L.3 (calliope)'), + ('Lead 4 (chiff)', 0, 0, 'L.4 (chiff)'), + ('Lead 5 (charang)', 0, 0, 'L.5 (charang)'), + ('Lead 6 (voice)', 0, 0, 'L.6 (voice)'), + ('Lead 7 (fifths)', 0, 0, 'L.7 (fifths)'), + ('Lead 8 (bass + lead)', 0, 1, 'L.8 (bass + lead)'), + ('Pad 1 (new age)', 0, 0, 'P.1 (new age)'), + ('Pad 2 (warm)', 0, 0, 'P.2 (warm)'), + ('Pad 3 (polysynth)', 0, 0, 'P.3 (polysynth)'), + ('Pad 4 (choir)', 0, 0, 'P.4 (choir)'), + ('Pad 5 (bowed)', 0, 0, 'P.5 (bowed)'), + ('Pad 6 (metallic)', 0, 0, 'P.6 (metallic)'), + ('Pad 7 (halo)', 0, 0, 'P.7 (halo)'), + ('Pad 8 (sweep)', 0, 0, 'P.8 (sweep)'), + ('FX 1 (rain)', -1, 0, 'Fx (rain)'), + ('FX 2 (soundtrack)', -1, 0, 'Fx (strack)'), + ('FX 3 (crystal)', 13, 0, 'Fx (crystal)'), + ('FX 4 (atmosphere)', 0, 0, 'Fx (atmosph.)'), + ('FX 5 (brightness)', 0, 0, 'Fx (bright.)'), + ('FX 6 (goblins)', -1, 0, 'Fx (goblins)'), + ('FX 7 (echoes)', -1, 0, 'Fx (echoes)'), + ('FX 8 (sci - fi)', -1, 0, 'Fx (sci - fi)'), + ('Sitar', 14, 0, ''), + ('Banjo', 14, 0, ''), + ('Shamisen', 14, 0, ''), + ('Koto', 14, 0, ''), + ('Kalimba', 1, 1, ''), + ('Bagpipe', 0, 0, ''), + ('Fiddle', 0, 0, ''), + ('Shanai', 0, 0, ''), + ('Tinkle Bell', 7, -1, ''), + ('Agogo', 0, 0, ''), + ('Steel Drums', 10, 0, ''), + ('Woodblock', 4, 0, ''), + ('Taiko Drum', 3, 0, ''), + ('Melodic Tom', 3, -1, ''), + ('Synth Drum', 3, 0, ''), + ('Reverse Cymbal', -1, 0, 'Rev. Cymbal'), + ('Guitar Fret Noise', -1, 0, 'Guitar F. Noise'), + ('Breath Noise', -1, 0, ''), + ('Seashore', -1, 0, ''), + ('Bird Tweet', -1, 0, ''), + ('Telephone Ring', -1, 0, 'Telephone'), + ('Helicopter', -1, 0, ''), + ('Applause', -1, 0, ''), + ('Gunshot', 0, 0, ''), + ('Percussion', -1, 0, ''), +) + +DRUM_INFO = ( + None, None, None, None, None, + None, None, None, None, None, + None, None, None, None, None, + None, None, None, None, None, + None, None, None, None, + ('Zap', -1, 0), + ('Brush hit hard', -1, 0), + ('Brush circle', -1, 0), + ('Brush hit soft', -1, 0), + ('Brush hit and circle', -1, 0), + ('Drumroll', -1, 0), + ('Castanets', -1, 0), + ('Snare Drum 3', -1, 0), + ('Drumsticks hitting', -1, 0), + ('Bass Drum 3', -1, 0), + ('Hard hit snare', -1, 0), + ('Bass Drum 2', 2, 10), + ('Bass Drum 1', 2, 6), + ('Side Stick', 4, 6), + ('Snare Drum 1', 3, 8), + ('Hand Clap', 4, 6), + ('Snare Drum 2', 3, 4), + ('Low Tom 2', 2, 6), + ('Closed Hi - hat', 3, 22), + ('Low Tom 1', 2, 13), + ('Pedal Hi - hat', 3, 22), + ('Mid Tom 2', 2, 15), + ('Open Hi - hat', 3, 18), + ('Mid Tom 1', 2, 20), + ('High Tom 2', 2, 23), + ('Crash Cymbal 1', 3, 17), + ('High Tom 1', 2, 23), + ('Ride Cymbal 1', 3, 24), + ('Chinese Cymbal', 3, 8), + ('Ride Bell', 3, 13), + ('Tambourine', 4, 18), + ('Splash Cymbal', 3, 18), + ('Cowbell', 4, 1), + ('Crash Cymbal 2', 3, 13), + ('Vibra Slap', 4, 2), + ('Ride Cymbal 2', 3, 13), + ('High Bongo', 4, 9), + ('Low Bongo', 4, 2), + ('Mute High Conga', 4, 8), + ('Open High Conga', 2, 22), + ('Low Conga', 2, 15), + ('High Timbale', 3, 13), + ('Low Timbale', 3, 8), + ('High Agog�', 4, 8), + ('Low Agog�', 4, 3), + ('Cabasa', 4, 20), + ('Maracas', 4, 23), + ('Short Whistle', -1, 0), + ('Long Whistle', -1, 0), + ('Short G�iro', 4, 17), + ('Long G�iro', 4, 11), + ('Claves', 4, 18), + ('High Wood Block', 4, 9), + ('Low Wood Block', 4, 5), + ('Mute Cu�ca', -1, 0), + ('Open Cu�ca', -1, 0), + ('Mute Triangle', 4, 17), + ('Open Triangle', 4, 22), + ('Shaker', 3, 22), + ('Jingle bell', -1, 0), + ('Bell tree', -1, 0), + ('Castanets', 4, 21), + ('Mute Surdo', 2, 14), + ('Open Surdo', 2, 7), +) + +expandMulDict = { + "64th": 4, + "32nd": 2, +} +tupletMulDict = { + "64th": 6, + "32nd": 5, + "16th": 4, + "eighth": 3, + "quarter": 2, +} + +durationMap = { + "128th": 0.125, + "64th": 0.25, + "32nd": 0.5, + "16th": 1, + "eighth": 2, + "quarter": 4, + "half": 8, + "whole": 16, +} + +MAX_TEMPO = 30 + +class FileError(Exception): + pass + +@lru_cache +def fraction2length(fraction: str) -> int: + if isinstance(fraction, str): + parts = fraction.split('/') + return int(parts[0]) * int(16 / int(parts[1])) + return 0 + +async def musescore2nbs(filepath: str, expandMultiplier=1, autoExpand=True, dialog=None) -> NbsSong: + """Convert a MuseScore file and return a NbsSong instance. + +if the conversation fails, this function returns None. + +Args: +- filepath: The path of the input file. .mscz and .mscx files are supported. +- expandMultiplier: Multiplys all note positions by this variable. + The default is 1, meaning not multiplying +- autoExpand: Optional; If it's True, the expand multiplier will be detected automatically. +- dialog: Optional; The ProgressDialog to be used for reporting progress. + +Return: + A NbsSong contains meta-information and notes' data (position, pitch, velocity + and tuning). + """ + + if autoExpand: + expandMultiplier = 1 + + # Reads the input file + + xml: etree.ElementTree = None + if filepath.endswith(".mscz"): + with zipfile.ZipFile(filepath, 'r') as zip: + filename: str = "" + for name in zip.namelist(): + if name.endswith(".mscx"): + filename = name + break + if filename: + with zip.open(filename) as file: + xml = etree.parse(file, parser=None) + else: + raise FileError("This file isn't a MuseScore file or it doesn't contain a MuseScore file.") + elif filepath.endswith(".mscx"): + xml = etree.parse(filepath, parser=None) + else: + raise FileError("This file isn't a MuseScore file") + + if version := xml.findtext('programVersion'): + if not version.startswith('3'): + raise NotImplementedError("This file is created by a older version of MuseScore. Please use MuseScore 3 to re-save the files before importing.") + + nbs: NbsSong = NbsSong() + + # Get meta-information + + header: Dict = nbs.header + score: etree.Element = xml.find("Score") + header.import_name = basename(filepath) + header.name = (score.xpath("metaTag[@name='workTitle']/text()") or ('',))[0] + header.author = (score.xpath("metaTag[@name='arranger']/text()") or ('',))[0] + header.orig_author = (score.xpath("metaTag[@name='composer']/text()") or ('',))[0] + + if timeSign := score.findtext("Staff/Measure/voice/TimeSig/sigN"): + header.time_sign = int(timeSign) + + if tempoTxt := score.findtext("Staff/Measure/voice/Tempo/tempo"): + bpm: float = 60 * float(tempoTxt) + tps: float = bpm * 4 / 60 + header.tempo = tps + + if dialog: + dialog.currentProgress.set(20) + await sleep(0.001) + + # Remove empty layers + + emptyStaffs: list = [] + staffCount = 0 + for staff in score.iterfind("Staff"): + staffCount += 1 + for elem in staff.xpath("Measure/voice/*"): + if elem.tag == "Chord": + break + else: + staffId = int(staff.get("id")) + emptyStaffs.append(staffId) + + # Get layer instruments from staff program IDs + + staffInsts = {} + for part in score.iterfind("Part"): + isPerc = bool(part.xpath("Instrument[@id='drumset']")) or bool(part.xpath("Staff/StaffType[@group='percussion']")) + program = int(part.find("Instrument/Channel/program").get("value")) + for staff in part.iterfind("Staff"): + staffId = int(staff.get("id")) + if staffId not in emptyStaffs: + # INST_INFO[-1] is the percussion (drumset) instrument + staffInsts[staffId] = INST_INFO[program] if not isPerc else INST_INFO[-1] + + tempo: float = header.tempo + + if dialog: + dialog.currentProgress.set(30) + await sleep(0.001) + + # Perform note auto-expanding (if specified) and tuplet detection + + hasComplexTuplets = False + for elem in score.xpath("Staff/Measure/voice/*"): + if elem.tag == "Tuplet": + normalNotes = int(elem.findtext("normalNotes")) + actualNotes = int(elem.findtext("actualNotes")) + if not hasComplexTuplets: + hasComplexTuplets = actualNotes != normalNotes + if hasComplexTuplets and autoExpand and (baseNote := elem.findtext("baseNote")): + if baseNote in tupletMulDict: + multiplier = max(tupletMulDict[baseNote], expandMultiplier) + if (tempo * multiplier) <= MAX_TEMPO: + expandMultiplier = multiplier + if autoExpand and (duration := elem.findtext("durationType")): + if duration in expandMulDict: + multiplier = max(expandMulDict[duration], expandMultiplier) + if (tempo * multiplier) <= MAX_TEMPO: + expandMultiplier = multiplier + + header.tempo = int(tempo * expandMultiplier) + header.time_sign *= expandMultiplier + + if dialog: + dialog.currentProgress.set(40) + await sleep(0.001) + + # Import note data + + baseLayer = -1 + ceilingLayer = baseLayer + for staff in score.iterfind("Staff"): + staffId = int(staff.get("id")) + if staffId in emptyStaffs: + continue + baseLayer = ceilingLayer + 1 + chords = 0 + rests = 0 + tick = 0 + lastTick = -1 + layer = -1 + staffInst = staffInsts[staffId][1] + for measure in staff.iterfind("Measure"): + beginTick = tick + endTick = -1 + innerBaseLayer = baseLayer + innerCeilingLayer = innerBaseLayer + for voi, voice in enumerate(measure.iterfind("voice")): + tick = beginTick + tick += fraction2length(voice.findtext("location/fractions")) * expandMultiplier + tupletNotesRemaining = 0 + normalNotes = 0 + actualNotes = 0 + for elem in voice: + #print(f'{elem.tag=}, {tick=}, {tickCheckpoint=}') + dots = int(elem.findtext("dots") or 0) + if elem.tag == "Chord": + chords += 1 + enoughSpace = int(tick) != int(lastTick) + # print(f'{int(lastTick)=} {int(tick)=} {enoughSpace=}') + for i, note in enumerate(elem.iterfind("Note")): + if voi > 0: + innerBaseLayer = innerCeilingLayer + if note.xpath("Spanner[@type='Tie']/prev"): + break + if not enoughSpace: + layer += 1 + else: + layer = innerBaseLayer+i + if layer >= len(nbs.layers): + nbs.layers.append(Dict({'name': "{} (v. {})".format(staffInsts[staffId][0], voi+1), 'lock':False, 'volume':100, 'stereo':100})) + inst = staffInst + isPerc = False + if inst > -1: + key = int(note.find("pitch").text) - 21 + else: + drumIndex = int(note.find("pitch").text) + if drumIndex > 23: # Vrevent access to None values in DRUM_INFO + _, inst, key = DRUM_INFO[drumIndex if drumIndex < len(DRUM_INFO) else 24] + key += 36 + isPerc = True + if inst == -1: inst = 0 + tuning = note.find("tuning") + pitch = int(float(tuning.text)) if tuning is not None else 0 + # TODO: Support relative velocity + vel = max(min(int(note.findtext("velocity") or 100), 127), 0) + nbs.notes.append(Dict({'tick': int(tick), 'layer': layer, 'inst': inst, + 'key': key, 'vel': vel, 'pan': 100, 'pitch': pitch, 'isPerc': isPerc})) + ceilingLayer = max(ceilingLayer, layer) + innerCeilingLayer = max(innerCeilingLayer, i) + lastTick = tick + length = durationMap[elem.findtext("durationType")] * (2-(1/(2**dots))) * expandMultiplier + if hasComplexTuplets and (tupletNotesRemaining > 0): + length = length * normalNotes / actualNotes + tupletNotesRemaining -= 1 + tick += length + elif elem.tag == "Rest": + rests += 1 + durationType = elem.findtext("durationType") + length = 0 + if durationType == "measure": + length = fraction2length(elem.findtext("duration")) * expandMultiplier + else: + length = int(durationMap[durationType] * (2-(1/(2**dots))) * expandMultiplier) + if hasComplexTuplets and (tupletNotesRemaining > 0): + length = length * normalNotes / actualNotes + tupletNotesRemaining -= 1 + tick += length + elif (elem.tag == "Tuplet") and hasComplexTuplets: + normalNotes = int(elem.findtext("normalNotes")) + actualNotes = int(elem.findtext("actualNotes")) + tupletNotesRemaining = actualNotes + endTick = max(endTick, tick) + tick = round(endTick) + # print(f'{tick=}, {chords=}, {rests=}, {ceilingLayer=}') + if dialog: + dialog.currentProgress.set(40 + staffId * 40 / staffCount) + await sleep(0.001) + + nbs.correctData() + return nbs + +if __name__ == '__main__': + # filepath = "sayonara_30_seconds.mscx" + # filepath = "chord.mscx" + # filepath = "Vì_yêu_cứ_đâm_đầu.mscx" + # filepath = "Ai_yêu_Bác_Hồ_Chí_Minh.mscz" + # filepath = "test.mscx" + # filepath = "test_microtones.mscx" + # filepath = "Shining_in_the_Sky.mscx" + # filepath = "voices.mscx" + # filepath = "AYBHCM_1_2.mscx" + # filepath = "inst.mscx" + # filepath = "Chay ngay di.mscx" + # filepath = "1note.mscx" + # filepath = "Ai_đưa_em_về_-_Nguyễn_Ánh_9_-_Phiên_bản_dễ_(Easy_version).mscx" + # filepath = 'halfTempo.mscx' + filepath = "A_Tender_Feeling_(Sword_Art_Online).mscz" + nbs = musescore2nbs(filepath, autoExpand=False) + print(nbs) + nbs.write(filepath.rsplit('.', 1)[0] + '.nbs') diff --git a/nbs2midi.py b/nbs2midi.py new file mode 100644 index 0000000..d380442 --- /dev/null +++ b/nbs2midi.py @@ -0,0 +1,203 @@ +# This file is a part of: +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# __________________________________________________________________________________ +# NBSTool is a tool to work with .nbs (Note Block Studio) files. +# Author: IoeCmcomc (https://github.com/IoeCmcomc) +# Programming language: Python +# License: MIT license +# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) +# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + + +from asyncio import sleep + +from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo + +from nbsio import NbsSong + +PERCUSSIONS = ( + #(percussion_key, instrument, key) + (35, 2, 64), + (36, 2, 60), + (37, 4, 60), + (38, 3, 62), + #(39, 4, 60), + (40, 3, 58), + #(41, 2, 60), + (42, 3, 76), + (43, 2, 67), + #(44, 3, 76), + (45, 2, 69), + (46, 2, 72), + (47, 2, 74), + (48, 2, 77), + (49, 2, 71), + (50, 3, 77), + (51, 3, 78), + (52, 3, 62), + (53, 3, 67), + (54, 3, 72), + (55, 3, 73), + (56, 4, 55), + #(57, 3, 67), + (58, 4, 56), + #(59, 3, 67), + (60, 4, 63), + (61, 4, 57), + (62, 4, 62), + (63, 2, 76), + (64, 3, 69), + #(65, 3, 67), + #(66, 3, 62), + #(67, 4, 62), + (68, 4, 58), + (69, 4, 74), + (70, 4, 77), + (73, 3, 71), + (74, 4, 65), + (75, 4, 72), + (76, 4, 64), + (77, 4, 59), + (80, 4, 71), + (81, 4, 76), + (82, 3, 78) + ) + +INST_PROGRAMS = { + 0: 1, # Harp: Piano + 1: 33, # Double bass: Acoustic bass + 5: 25, # Guitar: Acoustic guitar (nylon) + 6: 74, # Flute: Flute + 7: 10, # Bell: Glockenspiel + 8: 113, # Chime: Tinkle bell (wind chime) + 9: 14, # Xylophone: Xylophone + 10: 12, # Iron Xylophone: Vibraphone + 11: 1, + 12: 1, + 13: 81, # Bit: Lead 1 (square) + 14: 106, # Banjo: Banjo + 15: 6, # Pling: Electric piano 2 +} + +INST2PITCH = { + 2: 36, + 3: 44, + 4: 81, +} + +def firstInstInLayer(nbs: NbsSong, layer: int) -> int: + if layer > nbs['maxLayer']: + return 0 + for note in nbs['notes']: + if note['layer'] == layer: + return note['inst'] + return 0 + +class MsgComparator: + def __init__(self, msg) -> None: + self.msg = msg + self.isMeta = isinstance(msg, MetaMessage) + + def __lt__(self, other) -> bool: + if (not self.isMeta) and isinstance(other.msg, Message): + return self.msg.time < other.msg.time + else: + return False + +def absTrack2DeltaTrack(track) -> MidiTrack: + track.sort(key=MsgComparator) + ret = MidiTrack() + ret.append(track[0]) + for i in range(1, len(track)): + msg = track[i] + # print(msg.time - track[i-1].time) + ret.append(msg.copy(time=msg.time - track[i-1].time)) + return ret + + +async def nbs2midi(data: NbsSong, filepath: str, dialog = None): + headers, notes, layers = data['header'], data['notes'], data['layers'] + + timeSign = headers['time_sign'] + tempo = headers['tempo'] * 60 / 4 + height = headers['height'] + layersLen = len(layers) + + mid = MidiFile(type=1) + tpb = mid.ticks_per_beat + note_tpb = int(tpb / 4) + tracks = [] + + for i in range(data.maxLayer+1): + programCode = INST_PROGRAMS.get(firstInstInLayer(data, i), 1) - 1 + + track = MidiTrack() + track.append(MetaMessage('set_tempo', tempo=bpm2tempo(tempo), time=0)) + track.append(Message('program_change', program=programCode, time=0)) + + tracks.append(track) + if dialog: + dialog.currentProgress.set(25) # 25% + await sleep(0.001) + + accumulate_time = 0 + for note in notes: + abs_time = int(note['tick'] / timeSign * tpb) + pitch = note['key'] + 21 + trackIndex = note['layer'] + layerVel = 100 + if trackIndex < layersLen: + layerVel = layers[trackIndex]['volume'] + velocity = int(note['vel'] * (layerVel / 100) / 100 * 127) + + isPerc = False + if note['isPerc']: + inst: int = note['inst'] + for a, b, c in PERCUSSIONS: + if c == pitch and b == inst: + pitch = a + break + else: + pitch = INST2PITCH[inst] + isPerc = True + + if isPerc: + tracks[trackIndex].append(Message('note_on', channel=9, note=pitch, velocity=127, time=abs_time)) + tracks[trackIndex].append(Message('note_off', channel=9, note=pitch, velocity=127, time=abs_time + note_tpb)) + else: + tracks[trackIndex].append(Message('note_on', note=pitch, velocity=127, time=abs_time)) + tracks[trackIndex].append(Message('note_off', note=pitch, velocity=127, time=abs_time + note_tpb)) + + if dialog: + dialog.currentProgress.set(50) # 50% + await sleep(0.001) + mid.tracks = [absTrack2DeltaTrack(track) for track in tracks] + if dialog: + dialog.currentProgress.set(75) # 75% + await sleep(0.001) + + if not filepath.endswith('.mid'): + filepath += '.mid' + mid.save(filepath) + +if __name__ == "__main__": + # fn = 'graphite_diamond' + # fn = 'AYBHCM_1_2' + # fn = "The Ground's Colour Is Yellow" + # fn = "sandstorm" + # fn = "Through the Fire and Flames" + # fn = "Vì yêu cứ đâm đầu" + # fn = "Shining_in_the_Sky" + fn = "Megalovania - Super Smash Bros. Ultimate" + + data = NbsSong(fn + '.nbs') + data.correctData() + nbs2midi(data, fn) diff --git a/nbsio.py b/nbsio.py index e2f0539..38d063e 100644 --- a/nbsio.py +++ b/nbsio.py @@ -1,240 +1,351 @@ # This file is a part of: #‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ -# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ -# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ -# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ -# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ #__________________________________________________________________________________ # NBSTool is a tool to work with .nbs (Note Block Studio) files. # Author: IoeCmcomc (https://github.com/IoeCmcomc) # Programming language: Python # License: MIT license -# Version: 0,7.0 # Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) #‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ from struct import Struct from pprint import pprint -from random import shuffle from collections import deque -import operator +from operator import itemgetter +from typing import BinaryIO +import warnings +from warnings import warn + +from addict import Dict BYTE = Struct('= 3: - headers['length'] = readNumeric(f, SHORT) - else: - headers['length'] = None - headers['height'] = readNumeric(f, SHORT) #Height - headers['name'] = readString(f) #Name - headers['author'] = readString(f) #Author - headers['orig_author'] = readString(f) #OriginalAuthor - headers['description'] = readString(f) #Description - headers['tempo'] = readNumeric(f, SHORT)/100 #Tempo - headers['auto-saving'] = readNumeric(f, BYTE) == 1 #Auto-saving enabled - headers['auto-saving_time'] = readNumeric(f, BYTE) #Auto-saving duration - headers['time_sign'] = readNumeric(f, BYTE) #Time signature - headers['minutes_spent'] = readNumeric(f, INT) #Minutes spent - headers['left_clicks'] = readNumeric(f, INT) #Left clicks - headers['right_clicks'] = readNumeric(f, INT) #Right clicks - headers['block_added'] = readNumeric(f, INT) #Total block added - headers['block_removed'] = readNumeric(f, INT) #Total block removed - headers['import_name'] = readString(f) #MIDI file name - #Notes - tick = -1 - tickJumps = layerJumps = 0 - while True: - tickJumps = readNumeric(f, SHORT) - #if notes: notes[-1]['duration'] = tickJumps - if tickJumps == 0: break - tick += tickJumps - layer = -1 - while True: - layerJumps = readNumeric(f, SHORT) - if layerJumps == 0: break - layer += layerJumps - inst = readNumeric(f, BYTE) - key = readNumeric(f, BYTE)#+21 - if inst in {2, 3, 4}: - hasPerc = isPerc = True - if inst not in usedInsts[1]: usedInsts[1].append(inst) - else: - isPerc = False - if inst not in usedInsts[0]: usedInsts[0].append(inst) - duraKey = None - for idx, note in enumerate(notes): - if note['layer'] == layer: duraKey = idx - if duraKey is not None: - if notes: notes[duraKey]['duration'] = tick - notes[duraKey]['tick'] - notes.append({'tick':tick, 'layer':layer, 'inst':inst, 'key':key, 'isPerc':isPerc, 'duration':8}) - maxLayer = max(layer, maxLayer) - if headers['length'] is None: headers['length'] = tick + 1 - indexByTick = tuple([ (tk, tuple([notes.index(nt) for nt in notes if nt['tick'] == tk]) ) for tk in range(headers['length']+1) ]) - tick = tickJumps = layerJumps = layer = inst = key = duraKey = isPerc = tk = nt = None - #Layers - for i in range(headers['height']): - name = readString(f) #Layer name - vol = readNumeric(f, BYTE) #Volume - if sign: - stereo = readNumeric(f, BYTE) #Stereo - else: - stereo = 100 - layers.append({'name':name, 'volume':vol, 'stereo':stereo}) - name = vol = stereo = None - #Custom instrument - headers['inst_count'] = readNumeric(f, BYTE) - for i in range(headers['inst_count']): - name = readString(f) #Instrument name - file = readString(f) #Sound filename - pitch = readNumeric(f, BYTE) #Pitch - shouldPressKeys = bool(readNumeric(f, BYTE)) #Press key - customInsts.append({'name':name, 'filename':file, 'pitch':pitch, 'pressKeys':shouldPressKeys}) - sortedNotes = sorted(notes, key = operator.itemgetter('tick', 'layer') ) - data = {'headers':headers, 'notes':sortedNotes, 'layers':layers, 'customInsts':customInsts, 'IsOldVersion':IsOldVersion, 'hasPerc':hasPerc, 'maxLayer':maxLayer, 'usedInsts':usedInsts, 'indexByTick':indexByTick } - return data - -def opennbs(filename, printOutput=False): - data = readnbs(filename) - if printOutput: pprint(data) - return data - -def DataPostprocess(data): - headers = data['headers'] - notes = data['notes'] - usedInsts = [[], []] - maxLayer = 0 - data['hasPerc'] = False - for i, note in enumerate(data['notes']): - tick, inst, layer = note['tick'], note['inst'], note['layer'] - if inst in {2, 3, 4}: - data['hasPerc'] = note['isPerc'] = True - if inst not in usedInsts[1]: usedInsts[1].append(inst) - else: - note['isPerc'] = False - if inst not in usedInsts[0]: usedInsts[0].append(inst) - duraKey = None - for idx, note in enumerate(data['notes']): - if note['layer'] == layer: duraKey = idx - if duraKey is not None: - if i > 0: data['notes'][duraKey]['duration'] = tick - data['notes'][duraKey]['tick'] - else: - note['duration'] = 8 - maxLayer = max(layer, maxLayer) - data['headers']['length'] = tick - data['maxLayer'] = maxLayer - data['usedInsts'] = usedInsts - data['indexByTick'] = tuple([ (tk, set([notes.index(nt) for nt in notes if nt['tick'] == tk]) ) for tk in range(headers['length']+1) ]) - note = tick = inst = layer = duraKey = usedInsts = maxLayer = tk = nt = None - return data - -def writeNumeric(f, fmt, v): - f.write(fmt.pack(v)) - -def writeString(f, v): - writeNumeric(f, INT, len(v)) - f.write(v.encode()) - -def writenbs(filename, data): - if filename is not '' and data is not None: - data = DataPostprocess(data) - headers, notes, layers, customInsts, IsOldVersion = \ - data['headers'], data['notes'], data['layers'], data['customInsts'], data['IsOldVersion'] - with open(filename, "wb") as f: - #Header - if not IsOldVersion: - writeNumeric(f, SHORT, 0) - writeNumeric(f, BYTE, headers['file_version']) #Version - writeNumeric(f, BYTE, headers['vani_inst']) - if IsOldVersion or headers['file_version'] >= 3: - writeNumeric(f, SHORT, headers['length']) #Length - writeNumeric(f, SHORT, headers['height']) #Height - writeString(f, headers['name']) #Name - writeString(f, headers['author']) #Author - writeString(f, headers['orig_author']) #OriginalAuthor - writeString(f, headers['description']) #Description - writeNumeric(f, SHORT, int(headers['tempo']*100)) #Tempo - writeNumeric(f, BYTE, headers['auto-saving']) #Auto-saving enabled - writeNumeric(f, BYTE, headers['auto-saving_time']) #Auto-saving duration - writeNumeric(f, BYTE, headers['time_sign']) #Time signature - writeNumeric(f, INT, headers['minutes_spent']) #Minutes spent - writeNumeric(f, INT, headers['left_clicks']) #Left clicks - writeNumeric(f, INT, headers['right_clicks']) #Right clicks - writeNumeric(f, INT, headers['block_added']) #Total block added - writeNumeric(f, INT, headers['block_removed']) #Total block removed - writeString(f, headers['import_name']) #MIDI file name - #Notes - shuffle(notes) - sortedNotes = sorted(notes, key = operator.itemgetter('tick', 'layer') ) - #pprint(sortedNotes) - tick = layer = -1 - fstNote = sortedNotes[0] - for note in sortedNotes: - if tick != note['tick']: - if note != fstNote: - writeNumeric(f, SHORT, 0) - layer = -1 - writeNumeric(f, SHORT, note['tick'] - tick) - tick = note['tick'] - if layer != note['layer']: - writeNumeric(f, SHORT, note['layer'] - layer) - layer = note['layer'] - writeNumeric(f, BYTE, note['inst']) - writeNumeric(f, BYTE, note['key'])#-21 - writeNumeric(f, SHORT, 0) - writeNumeric(f, SHORT, 0) - #Layers - for layer in layers: - writeString(f, layer['name']) #Layer name - writeNumeric(f, BYTE, layer['volume']) #Volume - if not IsOldVersion: - writeNumeric(f, BYTE, layer['stereo']) #Stereo - #Custom instrument - pprint(customInsts) - if len(customInsts) == 0: writeNumeric(f, BYTE, 0) - else: - writeNumeric(f, BYTE, len(customInsts)) - if len(customInsts) > 0: - for customInst in customInsts: - writeString(f, customInst['name']) #Instrument name - writeString(f, customInst['filename']) #Sound filename - writeNumeric(f, BYTE, customInst['pitch']) #Pitch - writeNumeric(f, BYTE, customInst['pressKeys']) #Press key +NBS_VERSION = 5 + +warnings.filterwarnings(action='once') + +def read_numeric(f: BinaryIO, fmt: Struct) -> int: + '''Read the following bytes from file and return a number.''' + + raw = f.read(fmt.size) + rawInt = int.from_bytes(raw, byteorder='little', signed=True) + # print("{0:<2}{1:<20}{2:<10}{3:<11}".format(fmt.size, str(raw), raw.hex(), rawInt)) + return fmt.unpack(raw)[0] + +def read_string(f: BinaryIO) -> str: + '''Read the following bytes from file and return a ASCII string.''' + + length = read_numeric(f, INT) + raw = f.read(length) + # print("{0:<20}{1}".format(length, raw)) + return raw.decode('unicode_escape') # ONBS doesn't support UTF-8 + +def write_numeric(f: BinaryIO, fmt: Struct, v) -> None: + f.write(fmt.pack(v)) + +def write_string(f: BinaryIO, v) -> None: + if not v.isascii(): + warn("The string '{}' will be written to file as '{}'.".format(v, v.encode("ascii", "replace"))) + v = v.encode("ascii", "replace") + else: + v = v.encode() + write_numeric(f, INT, len(v)) + f.write(v) + +class NbsSong(Dict): + def __init__(self, f=None): + self.header = Dict({ + 'file_version': NBS_VERSION, + 'vani_inst': 16, + 'length': 0, + 'height': 0, + 'name': '', + 'author': '', + 'orig_author': '', + 'description': '', + 'auto_save': True, + 'auto_save_time': 10, + 'tempo': 1000, # 10 TPS + 'time_sign': 4, + 'minutes_spent': 0, + 'left_clicks': 0, + 'right_clicks': 0, + 'block_added': 0, + 'block_removed': 0, + 'import_name': '', + 'loop': False, + 'loop_max': 0, + 'loop_start': 0, + }) + self.notes = deque() + self.layers = deque() + self.customInsts = [] + self.appendix = None + if f: self.read(f) + + def __repr__(self): + return "".format(len(self.notes), len(self.layers), len(self.customInsts)) + + def readHeader(self, f: BinaryIO) -> None: + '''Read a .nbs file header from a file object''' + + header = self.header + header['length'] = None + readNumeric = read_numeric + readString = read_string + + #Header + first = readNumeric(f, SHORT) #Sign + if first != 0: #File is old type + header['file_version'] = file_version = 0 + header['vani_inst'] = 10 + header['length'] = first + else: #File is new type + header['file_version'] = file_version = readNumeric(f, BYTE) #Version + if file_version > NBS_VERSION: + raise NotImplementedError("This format version ({}) is not supported.".format(file_version)) + header['vani_inst'] = readNumeric(f, BYTE) + if file_version >= 3: + header['length'] = readNumeric(f, SHORT) + header['height'] = readNumeric(f, SHORT) #Height + header['name'] = readString(f) #Name + header['author'] = readString(f) #Author + header['orig_author'] = readString(f) #OriginalAuthor + header['description'] = readString(f) #Description + header['tempo'] = readNumeric(f, SHORT)/100 #Tempo + header['auto_save'] = readNumeric(f, BYTE) == 1 #auto_save enabled + header['auto_save_time'] = readNumeric(f, BYTE) #auto_save duration + header['time_sign'] = readNumeric(f, BYTE) #Time signature + header['minutes_spent'] = readNumeric(f, INT) #Minutes spent + header['left_clicks'] = readNumeric(f, INT) #Left clicks + header['right_clicks'] = readNumeric(f, INT) #Right clicks + header['block_added'] = readNumeric(f, INT) #Total block added + header['block_removed'] = readNumeric(f, INT) #Total block removed + header['import_name'] = readString(f) #MIDI file name + if file_version >= 4: + header['loop'] = readNumeric(f, BYTE) == 1 #Loop enabled + header['loop_max'] = readNumeric(f, BYTE) #Max loop count + header['loop_start'] = readNumeric(f, SHORT) #Loop start tick + self.header = header + + def read(self, fn) -> None: + '''Read a .nbs file from disk or URL.''' + + notes = deque() + maxLayer = 0 + usedInsts = [[], []] + hasPerc = False + layers = deque() + customInsts = [] + appendix = None + readNumeric = read_numeric + readString = read_string + + if fn != '': + if fn.__class__.__name__ == 'HTTPResponse': + f = fn + else: + f = open(fn, "rb") + try: + self.readHeader(f) + header = self.header + file_version = header.file_version + #Notes + tick = -1 + tickJumps = layerJumps = 0 + while True: + tickJumps = readNumeric(f, SHORT) + if tickJumps == 0: break + tick += tickJumps + layer = -1 + while True: + layerJumps = readNumeric(f, SHORT) + if layerJumps == 0: break + layer += layerJumps + inst = readNumeric(f, BYTE) + key = readNumeric(f, BYTE)#+21 + if file_version >= 4: + vel = readNumeric(f, BYTE) + pan = readNumeric(f, BYTE) + pitch = readNumeric(f, SHORT_SIGNED) + else: + vel = 100 + pan = 100 + pitch = 0 + if inst in {2, 3, 4}: + hasPerc = isPerc = True + if inst not in usedInsts[1]: usedInsts[1].append(inst) + else: + isPerc = False + if inst not in usedInsts[0]: usedInsts[0].append(inst) + notes.append(Dict({'tick':tick, 'layer':layer, 'inst':inst, 'key':key, 'vel':vel, 'pan':pan, 'pitch':pitch, 'isPerc':isPerc})) + maxLayer = max(layer, maxLayer) + # if header['length'] is None: + header['length'] = tick + 1 + # indexByTick = tuple([ (tk, tuple([notes.index(nt) for nt in notes if nt['tick'] == tk]) ) for tk in range(header['length']+1) ]) + #Layers + for i in range(header['height']): + name = readString(f) #Layer name + if header['file_version'] >= 4: + lock = readNumeric(f, BYTE) == 1 #Lock + else: + lock = False + vol = readNumeric(f, BYTE) #Volume + vol = 100 if vol == -1 else vol + stereo = readNumeric(f, BYTE) if file_version >= 2 else 100 #Stereo + layers.append(Dict({'name':name, 'lock':lock, 'volume':vol, 'stereo':stereo})) + name = vol = stereo = None + #Custom instrument + header['inst_count'] = readNumeric(f, BYTE) + for i in range(header['inst_count']): + name = readString(f) #Instrument name + file = readString(f) #Sound fn + pitch = readNumeric(f, BYTE) #Pitch + shouldPressKeys = bool(readNumeric(f, BYTE)) #Press key + customInsts.append(Dict({'name':name, 'fn':file, 'pitch':pitch, 'pressKeys':shouldPressKeys})) + #Rest of the file + appendix = f.read() + finally: + try: + f.close() + except: + pass + self.notes, self.layers, self.customInsts, self.hasPerc, self.maxLayer, self.usedInsts = \ + notes, layers, customInsts, hasPerc, maxLayer, (tuple(usedInsts[0]), tuple(usedInsts[1])) + if appendix: self.appendix = appendix + + def sortNotes(self) -> None: + self.notes = sorted(self.notes, key=itemgetter('tick', 'layer', 'key', 'inst')) + + def correctData(self) -> None: + '''Make song data consistent.''' + + self.sortNotes() + notes = self.notes + header = self.header + usedInsts = [[], []] + maxLayer = 0 + maxInst = 0 + tick = -1 + self.hasPerc = False + for i, note in enumerate(notes): + tick, inst, layer = note['tick'], note['inst'], note['layer'] + maxInst = max(maxInst, inst) + if inst in {2, 3, 4}: + self.hasPerc = note['isPerc'] = True + if inst not in usedInsts[1]: usedInsts[1].append(inst) + else: + note['isPerc'] = False + if inst not in usedInsts[0]: usedInsts[0].append(inst) + maxLayer = max(layer, maxLayer) + + header.length = tick + header.height = len(self.layers) + header.vani_inst = 16 if header.file_version > 0 else 10 + self.maxLayer = maxLayer + self.usedInsts = (tuple(usedInsts[0]), tuple(usedInsts[1])) + # self['indexByTick'] = tuple([ (tk, set([notes.index(nt) for nt in notes if nt['tick'] == tk]) ) for tk in range(header['length']+1) ]) + + def write(self, fn: str) -> None: + '''Save nbs data to a file on disk with the path given.''' + + if not fn.endswith('.nbs'): + fn += '.nbs' + + if fn != '' and self.header and self.notes: + writeNumeric = write_numeric + writeString = write_string + self.correctData() + header, notes, layers, customInsts = \ + self['header'], self['notes'], self['layers'], self['customInsts'] + file_version = header['file_version'] + + with open(fn, "wb") as f: + #Header + if file_version != 0: + writeNumeric(f, SHORT, 0) + writeNumeric(f, BYTE, file_version) #Version + writeNumeric(f, BYTE, header.get('vani_inst', 10)) + if (file_version == 0) or (file_version >= 3): + writeNumeric(f, SHORT, header['length']) #Length + writeNumeric(f, SHORT, header['height']) #Height + writeString(f, header['name']) #Name + writeString(f, header['author']) #Author + writeString(f, header['orig_author']) #OriginalAuthor + writeString(f, header['description']) #Description + writeNumeric(f, SHORT, int(header['tempo']*100)) #Tempo + writeNumeric(f, BYTE, header['auto_save']) #auto_save enabled + writeNumeric(f, BYTE, header['auto_save_time']) #auto_save duration + writeNumeric(f, BYTE, header['time_sign']) #Time signature + writeNumeric(f, INT, header['minutes_spent']) #Minutes spent + writeNumeric(f, INT, header['left_clicks']) #Left clicks + writeNumeric(f, INT, header['right_clicks']) #Right clicks + writeNumeric(f, INT, header['block_added']) #Total block added + writeNumeric(f, INT, header['block_removed']) #Total block removed + writeString(f, header['import_name']) #MIDI file name + if file_version >= 4: + writeNumeric(f, BYTE, header.get('loop', False)) #Loop enabled + writeNumeric(f, BYTE, header.get('loop_max', 0)) #Max loop count + writeNumeric(f, SHORT, header.get('loop_start', 0)) #Loop start tick + #Notes + tick = layer = -1 + fstNote = notes[0] + for note in notes: + if tick != note['tick']: + if note != fstNote: + writeNumeric(f, SHORT, 0) + layer = -1 + writeNumeric(f, SHORT, note['tick'] - tick) + tick = note['tick'] + if layer != note['layer']: + writeNumeric(f, SHORT, note['layer'] - layer) + layer = note['layer'] + writeNumeric(f, BYTE, note['inst']) + writeNumeric(f, BYTE, note['key'])#-21 + if file_version >= 4: + writeNumeric(f, BYTE, note.get('vel', 100)) + writeNumeric(f, BYTE, note.get('pan', 100)) + writeNumeric(f, SHORT_SIGNED, note.get('pitch', 0)) + # writeNumeric(f, SHORT, 0) + # writeNumeric(f, SHORT, 0) + writeNumeric(f, INT, 0) + #Layers + for layer in layers: + writeString(f, layer['name']) #Layer name + if file_version >= 4: + writeNumeric(f, BYTE, layer.get('lock', False)) #Lock + writeNumeric(f, BYTE, layer['volume']) #Volume + if file_version >= 2: + writeNumeric(f, BYTE, layer.get('stereo', 100)) #Stereo + #Custom instrument + if len(customInsts) == 0: writeNumeric(f, BYTE, 0) + else: + writeNumeric(f, BYTE, len(customInsts)) + if len(customInsts) > 0: + for customInst in customInsts: + writeString(f, customInst['name']) #Instrument name + writeString(f, customInst['fn']) #Sound fn + writeNumeric(f, BYTE, customInst['pitch']) #Pitch + writeNumeric(f, BYTE, customInst['pressKeys']) #Press key + #Appendix + if self.appendix: f.write(self.appendix) if __name__ == "__main__": - import sys - opennbs(sys.argv[1], sys.argv[2]) \ No newline at end of file + import sys + if len(sys.argv) > 1: + if len(sys.argv) == 2: in_ra = True + else: in_ra = sys.argv[2] + data = NbsSong(sys.argv[1]) + if in_ra: pprint(data) \ No newline at end of file diff --git a/ncfio.py b/ncfio.py index a3b01db..2a1cb56 100644 --- a/ncfio.py +++ b/ncfio.py @@ -1,42 +1,41 @@ # This file is a part of: #‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ -# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ -# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ -# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ -# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ -# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ +# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█ +# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███ +# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███ +# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███ +# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄ +# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██ #__________________________________________________________________________________ # NBSTool is a tool to work with .nbs (Note Block Studio) files. # Author: IoeCmcomc (https://github.com/IoeCmcomc) # Programming language: Python # License: MIT license -# Version: 0.7.0 # Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool) #‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ -import re, math +import re, math, operator def readncf(text): groups = re.findall(r"((?:1|2|4|8|16|32)\.?)(?:(#?[a-g])([1-3])|-)", text) - + notes = [] tick = 0 for tup in groups: dur = tup[0] if dur[-1] == '.': dur = 4 / int(dur[:-1]) * 1.5 else: dur = 4 / int(dur) - if bool(tup[1]): key = 27 + note.index(tup[1]) + 12*(int(tup[2])-1) + if bool(tup[1]): key = 27 + notes.index(tup[1]) + 12*(int(tup[2])-1) else: key = None if key is not None and dur >= 0.25: notes.append({'tick':tick, 'layer': 0, 'inst':0, 'key':key, 'isPerc': False, 'duration': dur*4}) tick += dur*4 headers = {} headers['file_version'] = 3 - headers['vani_inst'] = 16 + headers['vani_inst'] = 16 headers['length'] = 0 headers['height'] = 1 headers['name'] = '' @@ -51,7 +50,7 @@ def readncf(text): headers['left_clicks'] = 0 headers['right_clicks'] = 0 headers['block_added'] = 0 - headers['block_removed'] = 0 + headers['block_removed'] = 0 headers['import_name'] = '' headers['inst_count'] = 0 @@ -83,23 +82,22 @@ def writencf(data): while dur >= 0.125 and sub > 0: sub = 2**(int(math.log(dur*8, 2))-3) subi = int(4 / sub)# if idur > 4 else sub - #print('idur: {}; dur: {}; sub: {}; subi: {}, c: {}'.format(idur, dur, sub, subi, c)) if c == 0: if idur > 4: ele = '1' + ele else: - ele = str(subi) + ele + ele = str(subi) + ele else: ext.append('{}-'.format(subi)) dur -= sub c += 1 - + key = note['key'] - 27 - + ele += '{}{}'.format(tunes[key%12], key//12 + 1) + ' '.join(ext) - + out.append(ele) - + out = ' '.join(out) return out \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bb9309a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +addict~=2.3.0 +Pillow~=7.0.0 +pygubu~=0.11 +mido>=1.2.9 +lxml~=4.6.3 +Pillow~=7.1.0 \ No newline at end of file diff --git a/ui/256x256.png b/ui/256x256.png new file mode 100644 index 0000000..6f62e8e Binary files /dev/null and b/ui/256x256.png differ diff --git a/ui/aboutdialog.ui b/ui/aboutdialog.ui new file mode 100644 index 0000000..14e9437 --- /dev/null +++ b/ui/aboutdialog.ui @@ -0,0 +1,57 @@ + + + + 100 + true + About this application... + 200 + + + 200 + 200 + + true + both + 10 + True + top + + + + left + {Arial} 57 {} + 256x256.png + NBSTool + + True + top + + + + + + center + A tool to work with Open Note Block Studio files. +Version: 1.0.0 +Author: IoeCmcomc + + 10 + True + top + + + + + + github + GitHub + + True + bottom + + + + + + + diff --git a/ui/datapackexportdialog.ui b/ui/datapackexportdialog.ui new file mode 100644 index 0000000..84ce9da --- /dev/null +++ b/ui/datapackexportdialog.ui @@ -0,0 +1,58 @@ + + + + 100 + true + 5 + 5 + Datapack export - NBSTool + 200 + + + 200 + 200 + + true + both + 10 + 5 + True + top + + + + Unique scorebroad ID: + + True + top + + + + + + true + key + + x + 10 + True + top + + + + + + export + normal + disabled + Export + + True + bottom + + + + + + + diff --git a/ui/midiexportdialog.ui b/ui/midiexportdialog.ui new file mode 100644 index 0000000..f5be65a --- /dev/null +++ b/ui/midiexportdialog.ui @@ -0,0 +1,95 @@ + + + + 100 + true + none + 200 + + + 200 + 200 + + true + both + 3 + 5 + True + top + + + + Export location + 200 + + nw + true + both + 5 + 5 + True + top + + + + exportModeChanged + left + Export all files in a folder: + folder + string:exportMode + + nw + True + top + + + + + + disabled + string:exportPath + Select folder to export + directory + + nw + x + True + top + + + + + + exportModeChanged + left + Export each file in the same folder as the original file. Only ask export paths for untitled files. + + current + string:exportMode + 500 + + nw + True + top + + + + + + + + export + normal + Export + + ne + 5 + True + top + + + + + + + diff --git a/ui/musescoreimportdialog.ui b/ui/musescoreimportdialog.ui new file mode 100644 index 0000000..50e6d2a --- /dev/null +++ b/ui/musescoreimportdialog.ui @@ -0,0 +1,119 @@ + + + + 100 + false + 200 + + + 200 + 5 + 200 + + true + both + 5 + 3 + True + top + + + + 200 + 5 + 5 + Select files + 200 + + true + both + True + top + + + + string:filePaths + + true + x + True + left + + + + + + browse + Browse... + + True + left + + + + + + + + 200 + 5 + 5 + Options + 200 + + true + both + 7 + True + top + + + + autoExpandChanged + Automatically expand the spaces between notes to fit as many notes as possible. + boolean:autoExpand + + nw + True + top + + + + + + 1 + Expand multiplier: +(will be ignored if the calculated tempo > 30 TPS) + 300 + horizontal + 1 + disabled + 1 + 30 + string:expandMult + + true + x + True + top + + + + + + + + onImport + disabled + Import + + e + True + bottom + + + + + + + diff --git a/ui/progressdialog.ui b/ui/progressdialog.ui new file mode 100644 index 0000000..36b159d --- /dev/null +++ b/ui/progressdialog.ui @@ -0,0 +1,104 @@ + + + + 100 + true + 200 + + + 200 + 200 + + nw + true + both + 10 + 10 + True + top + + + + + nw + true + both + True + top + + + + + + Curent: + string:currentText + + nw + True + top + + + + + + horizontal + int:currentProgress + + nw + x + True + top + + + + + + Total: + string:totalText + + nw + True + top + + + + + + 550 + horizontal + int:totalProgress + + x + True + top + + + + + + 15 + + nw + true + both + True + top + + + + onCancel + normal + Cancel + + se + True + bottom + + + + + + + + + diff --git a/ui/toplevel.ui b/ui/toplevel.ui new file mode 100644 index 0000000..5d6f136 --- /dev/null +++ b/ui/toplevel.ui @@ -0,0 +1,899 @@ + + + + icon.ico + 256x256.png + 350|370 + both + false + NBS Tool + + + + true + both + True + top + + + + nw + Files + 200 + + true + both + 10 + 5 + True + top + + + + both + true + + true + both + 5 + 5 + True + top + + + + extended + + true + True + top + + + + w + w + 20 + true + File path + true + true + 200 + + + + + e + w + 50 + false + Length + false + true + 80 + + + + + w + w + 20 + true + Name + false + true + 150 + + + + + w + w + 20 + true + Author + false + true + 150 + + + + + w + w + 20 + true + Original author + false + true + 200 + + + + + + + + + openFiles + Open... + + 3 + True + left + + + + + + saveFiles + disabled + Save... + + True + left + + + + + + removeSelectedFiles + disabled + Remove + + 5 + True + right + + + + + + addFiles + Add... + + True + right + + + + + + + + 250 + nw + Tools + 200 + + true + both + 10 + 5 + True + top + + + + + true + both + 5 + 3 + True + top + + + + Header + + + 200 + 200 + + True + top + + + + 200 + Barless.TNotebook + 200 + + true + both + True + top + + + + + + 0 + 200 + 0 + flat + 200 + + True + top + + + + center + At least one file must be selected and all selected files must have the same format number in order to modify header data. + + x + 20 + 10 + True + top + + + + + + + + + + + + 200 + 5 + 5 + 200 + + True + top + + + + 200 + sw + 5 + Auto-save + 200 + + true + both + 3 + 3 + True + left + + + + onAutosaveCheckBtn + Enabled + boolean:headerAutosave + + 0 + 2 + True + 0 + 1 + + + + + + disabled + Inteval: + + 0 + True + 1 + w + 1 + + + + + + 0 + 1 + disabled + string:headerAutosaveInterval + 255 + focusout + isInteger + 4 + + 1 + True + 1 + e + 0 + 1 + + + + + + + + 200 + sw + Stats + 200 + + true + both + 3 + 3 + True + left + + + + Minutes spent: + + 0 + True + 0 + w + 0 + 1 + 0 + + + + + + Left-clicks: + + 0 + True + 1 + w + 0 + 1 + + + + + + Right-clicks: + + 0 + True + 2 + w + 0 + 1 + + + + + + Note blocks added: + + 0 + True + 3 + w + 0 + 1 + + + + + + Note blocks removed: + + 0 + True + 4 + w + 0 + 1 + + + + + + 0 + 1 + string:headerMinuteSpent + 2147483647 + focusout + isInteger + 11 + + 1 + True + 0 + e + 1 + 0 + + + + + + 0 + 1 + + + string:headerLeftClicks + 2147483647 + focusout + isInteger + 11 + + 1 + True + 1 + e + 1 + + + + + + 0 + 1 + string:headerRightClicks + 2147483647 + focusout + isInteger + 11 + + 1 + True + 2 + e + 1 + + + + + + 0 + 1 + string:headerBlockAdded + 2147483647 + focusout + isInteger + 11 + + 1 + True + 3 + e + 1 + + + + + + 0 + 1 + string:headerBlockRemoved + 2147483647 + focusout + isInteger + 11 + + 1 + True + 4 + e + 1 + + + + + + + + 200 + sw + Loop + 200 + + true + both + 3 + 3 + True + left + + + + onLoopCheckBtn + Enabled + boolean:headerLoop + + 0 + 2 + True + 0 + 1 + + + + + + Max loop count: + + 0 + True + 1 + w + 1 + + + + + + Loop start tick: + + 0 + True + 2 + sw + 1 + + + + + + 0 + 1 + string:headerLoopCount + 255 + focusout + isInteger + 4 + + 1 + True + 1 + e + 1 + + + + + + 0 + 1 + string:headerLoopStart + 32767 + + focusout + isInteger + 6 + + 1 + True + 2 + e + 1 + + + + + + + + + + + + + + + + + + Format + + + 10 + 200 + + True + top + + + + No file selected. + string:selectedFilesVerStr + + True + top + + + + + + horizontal + + x + 10 + 5 + True + top + + + + + + Changes files format version to: + + True + top + + + + + + readonly + string:formatComboCurrent + + True + top + + + + + + Changing files's format to a older version can cause data to be lost + + + x + True + top + + + + + + + + + + Flip + + + 5 + 5 + 200 + + True + top + + + + left + Filp notes: + + True + top + + + + + + Horizontally + boolean:flipHorizontallyCheckVar + + True + top + + + + + + Vertically + boolean:flipVerticallyCheckVar + + True + top + + + + + + + + + + Arrange + + + 200 + 7 + 5 + 200 + + True + top + + + + onArrangeModeChanged + Not arrange + none + string:arrangeMode + + nw + True + top + + + + + + onArrangeModeChanged + Collapse all notes + collapse + string:arrangeMode + + nw + True + top + + + + + + onArrangeModeChanged + Arrange by instruments + instruments + string:arrangeMode + + nw + True + top + + + + + + disabled + Treats percussions as an instrument + boolean:groupPerc + + nw + 20 + True + top + + + + + + + + + + + + applyTool + disabled + Apply + + e + 10 + True + top + + + + + + + + + se + True + bottom + + + + + + + + false + + + File + false + 0 + + + Ctrl+O + openFiles + Open + 0 + + + + + Ctrl+S + saveFiles + Save + disabled + 0 + + + + + Ctrl+Shift+S + saveAll + Save all + disabled + 5 + + + + + + + + Esc + onClose + Quit + 0 + + + + + + + Import + false + 0 + + + callMuseScoreImportDialog + from MuseScore files... + 0 + + + + + + + Export + false + 0 + + + callDatapackExportDialog + as datapack... + disabled + 0 + + + + + callMidiExportDialog + as MIDI file... + disabled + 3 + + + + + + + Help + help + false + 0 + + + callAboutDialog + About + 0 + + + + + + diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..1a83910 --- /dev/null +++ b/version.txt @@ -0,0 +1,28 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'040904B0', + [StringStruct(u'CompanyName', u''), + StringStruct(u'FileDescription', u'A tool to work with .nbs (Note Block Studio) files.'), + StringStruct(u'FileVersion', u'1.0 (32 bit)'), + StringStruct(u'InternalName', u'nbstool'), + StringStruct(u'LegalCopyright', u'\xa9 IoeCmcomc.'), + StringStruct(u'OriginalFilename', u'NBSTool-32bit-v1.0_pyinstaller.exe'), + StringStruct(u'ProductName', u'NBSTool'), + StringStruct(u'ProductVersion', u'1.0')]) + ]), + VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) + ] +) \ No newline at end of file