Skip to content

Commit d0be094

Browse files
committed
feat: Add Impulse Tracker (.it) files exporting feature
- Audio samples are saved as uncompressed, normalized, 16-bit, single-channel data - A sample is mapped to an instrument - One empty sample and an empty instrument are added to fix the weird pitch shifting of the last sample - The attached message is not written because I'm lazy - Due to limitations of the Impulse Tracker format: - Most of the text fields are truncated to 26 characters, except channel names, which are limited to 20 characters - A note may not have its volume, panning, and pitch applied because the .it format only has a volume column and an effect column - If the length of a song is more than 40000 ticks, the rest of the song will not be saved - It seems like different programs play the exported file differently. Notably, the sample' pitch is sometimes shifted.
1 parent 977d6d4 commit d0be094

9 files changed

+791
-69
lines changed

common.py

+17-5
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
import sys
2121
from collections import namedtuple
2222
from os.path import abspath, dirname, join, normpath
23+
from functools import lru_cache
24+
from typing import Optional
25+
26+
from pydub import AudioSegment
2327

2428
# from main import __file__ as __mainfile__
2529

@@ -225,16 +229,24 @@
225229
)
226230

227231
NBS_PITCH_IN_MIDI_PITCHBEND = 40.96
232+
SOUND_FOLDER = "sounds"
228233

229-
230-
BASE_RESOURCE_PATH = None
234+
BASE_RESOURCE_PATH = ''
231235
if getattr(sys, 'frozen', False): # PyInstaller
232236
BASE_RESOURCE_PATH = sys._MEIPASS # type: ignore
233237
elif '__compiled__' in globals(): # Nuitka
234238
BASE_RESOURCE_PATH = dirname(__file__)
235239
else:
236240
BASE_RESOURCE_PATH = abspath('.')
237-
assert BASE_RESOURCE_PATH is not None
241+
assert BASE_RESOURCE_PATH != ''
242+
243+
def resource_path(*args: str):
244+
return normpath(join(BASE_RESOURCE_PATH, *args))
238245

239-
def resource_path(*args):
240-
return normpath(join(BASE_RESOURCE_PATH, *args))
246+
@lru_cache(maxsize=32)
247+
def load_sound(path: str) -> AudioSegment:
248+
"""A patched version of nbswave.audio.load_song() which caches loaded sounds"""
249+
if not path:
250+
return AudioSegment.empty()
251+
else:
252+
return AudioSegment.from_file(path)

customwidgets/__init__.py

-41
Original file line numberDiff line numberDiff line change
@@ -15,44 +15,3 @@
1515
# License: MIT license
1616
# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool)
1717
# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
18-
19-
20-
if __name__ == '__init__': # Pygubu import call
21-
from wrapmessage import WrapMessage
22-
from checkablelabelframe import CheckableLabelFrame
23-
else: # Normal import call
24-
from .wrapmessage import WrapMessage
25-
from .checkablelabelframe import CheckableLabelFrame
26-
27-
from packaging.version import Version
28-
import pygubu
29-
30-
if Version(pygubu.__version__) >= Version('0.24'):
31-
from pygubu.component.builderobject import BuilderObject
32-
from pygubu.api.v1 import register_widget
33-
else:
34-
from pygubu import BuilderObject, register_widget
35-
36-
class WrapMessageBuilder(BuilderObject):
37-
class_ = WrapMessage
38-
39-
OPTIONS_STANDARD = ('anchor', 'background', 'borderwidth', 'cursor', 'font',
40-
'foreground', 'highlightbackground', 'highlightcolor',
41-
'highlightthickness', 'padx', 'pady', 'relief', 'takefocus',
42-
'text', 'textvariable')
43-
OPTIONS_SPECIFIC = ('aspect', 'justify', 'width', 'padding')
44-
properties = OPTIONS_STANDARD + OPTIONS_SPECIFIC
45-
46-
class CheckableLabelFrameBuilder(BuilderObject):
47-
class_ = CheckableLabelFrame
48-
container = True
49-
50-
OPTIONS_STANDARD = ('cursor', 'takefocus', 'style')
51-
OPTIONS_SPECIFIC = ('borderwidth', 'relief', 'padding', 'height', 'width', 'labelanchor', 'text', 'underline', 'variable', 'command')
52-
53-
properties = OPTIONS_STANDARD + OPTIONS_SPECIFIC
54-
55-
register_widget('customwidgets.wrapmessage', WrapMessageBuilder,
56-
'WrapMessage', ('tk', 'Custom'))
57-
register_widget('customwidgets.checkablelabelframe', CheckableLabelFrameBuilder,
58-
'CheckableLabelFrame', ('ttk', 'Custom'))

customwidgets/builder.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# This file is a part of:
2+
# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
3+
# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█
4+
# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███
5+
# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███
6+
# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███
7+
# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███
8+
# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███
9+
# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄
10+
# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██
11+
# __________________________________________________________________________________
12+
# NBSTool is a tool to work with .nbs (Note Block Studio) files.
13+
# Author: IoeCmcomc (https://github.com/IoeCmcomc)
14+
# Programming language: Python
15+
# License: MIT license
16+
# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool)
17+
# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
18+
19+
20+
try:
21+
from pygubu.api.v1 import (
22+
BuilderObject,
23+
register_widget,
24+
)
25+
except ImportError as e:
26+
from pygubu import BuilderObject, register_widget # type: ignore
27+
28+
from wrapmessage import WrapMessage
29+
from checkablelabelframe import CheckableLabelFrame
30+
31+
class WrapMessageBuilder(BuilderObject): # type: ignore
32+
class_ = WrapMessage
33+
34+
OPTIONS_STANDARD = ('anchor', 'background', 'borderwidth', 'cursor', 'font',
35+
'foreground', 'highlightbackground', 'highlightcolor',
36+
'highlightthickness', 'padx', 'pady', 'relief', 'takefocus',
37+
'text', 'textvariable')
38+
OPTIONS_SPECIFIC = ('aspect', 'justify', 'width', 'padding')
39+
properties = OPTIONS_STANDARD + OPTIONS_SPECIFIC
40+
41+
class CheckableLabelFrameBuilder(BuilderObject): # type: ignore
42+
class_ = CheckableLabelFrame
43+
container = True
44+
45+
OPTIONS_STANDARD = ('cursor', 'takefocus', 'style')
46+
OPTIONS_SPECIFIC = ('borderwidth', 'relief', 'padding', 'height', 'width', 'labelanchor', 'text', 'underline', 'variable', 'command')
47+
48+
properties = OPTIONS_STANDARD + OPTIONS_SPECIFIC
49+
50+
register_widget(
51+
'customwidgets.WrapMessage', WrapMessageBuilder, 'WrapMessage', ('tk', 'Custom'))
52+
register_widget(
53+
'customwidgets.CheckableLabelFrame', CheckableLabelFrameBuilder,
54+
'CheckableLabelFrame', ('ttk', 'Custom'))

main.py

+39-14
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
import logging
6464
import sys
6565

66-
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
66+
# logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
6767

6868
import pygubu
6969
import pygubu.widgets.combobox
@@ -90,6 +90,7 @@
9090
from nbs2midi import nbs2midi
9191
from nbsio import NBS_VERSION, VANILLA_INSTS, Instrument, Layer, NbsSong, Note
9292
from lyric_parser import lyric2captions
93+
from nbs2impulsetracker import nbs2it
9394

9495
__version__ = '1.3.0'
9596

@@ -409,6 +410,8 @@ def on_fileTable_select(event):
409410
2, state="normal" if selectionNotEmpty else "disable")
410411
exportMenu.entryconfig(
411412
3, state="normal" if selectionNotEmpty else "disable")
413+
exportMenu.entryconfig(
414+
4, state="normal" if selectionNotEmpty else "disable")
412415

413416
self.fileTable.bind("<<TreeviewSelect>>", on_fileTable_select)
414417

@@ -735,6 +738,9 @@ def callAudioExportDialog(self):
735738

736739
def callJsonExportDialog(self):
737740
JsonExportDialog(self.toplevel, self).run()
741+
742+
def callImpulseExportDialog(self):
743+
ImpulseExportDialog(self.toplevel, self).run()
738744

739745
def callAboutDialog(self):
740746
dialogue = AboutDialog(self.toplevel, self)
@@ -1144,6 +1150,8 @@ def __init__(self, master, parent, fileExt: str, title: Optional[str], progressT
11441150

11451151
self.exportModeChanged()
11461152

1153+
self.shouldCompactNotes = True
1154+
11471155
def run(self):
11481156
self.d.run()
11491157

@@ -1201,7 +1209,8 @@ async def work(dialog: ProgressDialog):
12011209
dialog.currentText.set(f"Current file: {filePath}")
12021210
# Prevent data from unintended changes
12031211
songData: NbsSong = deepcopy(songsData[i])
1204-
compactNotes(songData, True)
1212+
if self.shouldCompactNotes:
1213+
compactNotes(songData, True)
12051214
songData.correctData()
12061215
dialog.currentProgress.set(10) # 10%
12071216
await func(songData, filePath, dialog)
@@ -1257,6 +1266,25 @@ async def nbs2json(self, data: NbsSong, filepath: str, dialog: ProgressDialog):
12571266
await sleep(0.001)
12581267

12591268

1269+
def checkFFmpeg(ps: str = '') -> bool:
1270+
if not (which('ffmpeg') and which('ffprobe')):
1271+
instructionMsg = ''
1272+
if os.name == 'nt':
1273+
instructionMsg = """
1274+
Make sure there are ffmpeg.exe and ffprobe.exe inside the ffmpeg/bin folder.
1275+
If not, you can download ffmpeg then put these two files in ffmpeg/bin folder."""
1276+
elif os.name == 'posix':
1277+
instructionMsg = """
1278+
Make sure the ffmpeg package is installed in the system.
1279+
Use "sudo apt install ffmpeg" command to install ffmpeg."""
1280+
instructionMsg = "NBSTool can't find ffmpeg, which is required to render audio." + instructionMsg
1281+
if ps:
1282+
instructionMsg += '\n' + ps
1283+
showwarning("ffmpeg not found", instructionMsg)
1284+
return False
1285+
else:
1286+
return True
1287+
12601288
class AudioExportDialog(ExportDialog):
12611289
def __init__(self, master, parent):
12621290
self.formatVar: tk.StringVar
@@ -1278,18 +1306,7 @@ def __init__(self, master, parent):
12781306
self.stereo.set(True) # type: ignore
12791307
self.includeLocked.set(True) # type: ignore
12801308

1281-
if not (which('ffmpeg') and which('ffprobe')):
1282-
instructionMsg = ''
1283-
if os.name == 'nt':
1284-
instructionMsg = """
1285-
Make sure there are ffmpeg.exe and ffprobe.exe inside the ffmpeg/bin folder.
1286-
If not, you can download ffmpeg then put these two files in ffmpeg/bin folder."""
1287-
elif os.name == 'posix':
1288-
instructionMsg = """
1289-
Make sure the ffmpeg package is installed in the system.
1290-
Use "sudo apt install ffmpeg" command to install ffmpeg."""
1291-
showwarning("ffmpeg not found",
1292-
"NBSTool can't find ffmpeg, which is required to render audio." + instructionMsg)
1309+
checkFFmpeg()
12931310

12941311
def formatChanged(self, *args):
12951312
self.fileExt = '.' + self.builder.get_object('formatCombo').current()
@@ -1307,6 +1324,14 @@ async def audioExport(self, data: NbsSong, filepath: str, dialog: ProgressDialog
13071324
ignore_missing_instruments=ignoreMissingSounds)
13081325

13091326

1327+
class ImpulseExportDialog(ExportDialog):
1328+
def __init__(self, master, parent):
1329+
super().__init__(master, parent, '.it', "Impulse Tracker exporting",
1330+
"Exporting {} files to Impulse Tracker format (.it)...", nbs2it)
1331+
1332+
if not checkFFmpeg():
1333+
self.d.destroy()
1334+
13101335
def parseFilePaths(string: str) -> tuple:
13111336
strLen = len(string)
13121337
ret: List[str] = []

nbs2audio.py

+24-3
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1+
# This file is a part of:
2+
# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
3+
# ███▄▄▄▄ ▀█████████▄ ▄████████ ███ ▄██████▄ ▄██████▄ ▄█
4+
# ███▀▀▀██▄ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███ ███ ███
5+
# ███ ███ ███ ███ ███ █▀ ▀███▀▀██ ███ ███ ███ ███ ███
6+
# ███ ███ ▄███▄▄▄██▀ ███ ███ ▀ ███ ███ ███ ███ ███
7+
# ███ ███ ▀▀███▀▀▀██▄ ▀███████████ ███ ███ ███ ███ ███ ███
8+
# ███ ███ ███ ██▄ ███ ███ ███ ███ ███ ███ ███
9+
# ███ ███ ███ ███ ▄█ ███ ███ ███ ███ ███ ███ ███▌ ▄
10+
# ▀█ █▀ ▄█████████▀ ▄████████▀ ▄████▀ ▀██████▀ ▀██████▀ █████▄▄██
11+
# __________________________________________________________________________________
12+
# NBSTool is a tool to work with .nbs (Note Block Studio) files.
13+
# Author: IoeCmcomc (https://github.com/IoeCmcomc)
14+
# Programming language: Python
15+
# License: MIT license
16+
# Source codes are hosted on: GitHub (https://github.com/IoeCmcomc/NBSTool)
17+
# ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
18+
19+
120
from asyncio import sleep
221
from os import environ
322
from os import name as os_name
423
from typing import Optional, Sequence
524

625
from pydub import AudioSegment
726
from pynbs import File, Header, Instrument, Layer, Note
27+
28+
from common import load_sound
29+
830
from nbswave import SongRenderer, audio, nbs
931
from nbswave.main import MissingInstrumentException
1032

11-
from common import resource_path
33+
from common import resource_path, SOUND_FOLDER
1234
from nbsio import VANILLA_INSTS, NbsSong
1335

14-
15-
SOUND_FOLDER = resource_path('sounds')
36+
audio.load_sound = load_sound
1637

1738
def convert(data: NbsSong) -> File:
1839
oldHeader = data.header

0 commit comments

Comments
 (0)