Source code for chiptunesak.goat_tracker

# Code to import and export goattracker .sng files (both regular and stereo)
#
# Notes:
# - This code ignores multispeed considerations (for now)

from os import path, listdir
from os.path import isfile, join
import copy
from dataclasses import dataclass
from chiptunesak import constants  # import ARCH, C0_MIDI_NUM, project_to_absolute_path
from chiptunesak import base
from chiptunesak.byte_util import read_binary_file
from chiptunesak import rchirp
from chiptunesak.errors import *


DEFAULT_INSTR_PATH = 'res/gtInstruments/'
DEFAULT_MAX_PAT_LEN = 126

# GoatTracker constants
GT_FILE_HEADER = b'GTS5'
GT_INSTR_BYTE_LEN = 25
GT_DEFAULT_TEMPO = 6
GT_DEFAULT_FUNKTEMPOS = [9, 6]  # default alternating tempos, from GT's gplay.c

# All these MAXes are the same for goattracker 2 (1SID) and goattracker 2 stereo (2SID)
# Most found in gcommon.h
GT_MAX_SUBTUNES_PER_SONG = 32  # Each subtune gets its own orderlist of patterns
# "song" means a collection of independently-playable subtunes
GT_MAX_ELM_PER_ORDERLIST = 255  # at minimum, it must contain the endmark and following byte
GT_MAX_INSTR_PER_SONG: int = 63
GT_MAX_PATTERNS_PER_SONG = 208  # patterns can be shared across channels and subtunes
# Can populate rows 0-127, 128 is end marker.  Min row count allowed is 1.
GT_MAX_ROWS_PER_PATTERN = 128
GT_MAX_TABLE_LEN = 255

GT_REST = 0xBD  # A rest in goattracker means NOP, not rest
GT_NOTE_OFFSET = 0x60  # Note value offset
GT_MAX_NOTE_VALUE = 0xBF  # Maximum possible value for note
GT_KEY_OFF = 0xBE
GT_KEY_ON = 0xBF
GT_OL_RST = 0xFF  # order list restart marker
GT_PAT_END = 0xFF  # pattern end
GT_TEMPO_CHNG_CMD = 0x0F


[docs]class GoatTracker(base.ChiptuneSAKIO): """ The IO interface for GoatTracker and GoatTracker Stereo Supports conversions between RChirp and GoatTracker .sng format """ @classmethod def cts_type(cls): return 'GoatTracker' def __init__(self): base.ChiptuneSAKIO.__init__(self) self.set_options(max_pattern_len=DEFAULT_MAX_PAT_LEN, # max pattern length if no given patterns instruments=[], # gt instrument assignments, in order end_with_repeat=False, # default is to stop GoatTracker from repeating music arch=constants.DEFAULT_ARCH) # architecture (for import to RChirp)
[docs] def set_options(self, **kwargs): """ Sets options for this module, with validation when required :param kwargs: keyword arguments for options :type kwargs: keyword arguments """ for op, val in kwargs.items(): op = op.lower() # All option names must be lowercase # Check for legal maximum pattern length if op == 'max_pattern_len': if not (1 <= val <= GT_MAX_ROWS_PER_PATTERN): raise Exception("Error: max rows for a pattern out of range") elif op == 'instruments': # Check to be sure instrument names don't include extensions for i, ins_name in enumerate(val): if ins_name[-4:] == '.ins': val[i] = ins_name[:-4] elif op == 'arch': if val not in constants.ARCH: raise ChiptuneSAKValueError(f'Error: Unknown architecture {val}') # Now set the option self._options[op] = val
[docs] def to_bin(self, rchirp_song, **kwargs): """ Convert an RChirpSong into a GoatTracker .sng file format :param rchirp_song: rchirp data :type rchirp_song: MChirpSong :return: sng binary file format :rtype: bytearray :keyword options: * **end_with_repeat** (bool) - True if song should repeat when finished * **max_pattern_len** (int) - Maximum pattern length to use. Must be <= 127 * **instruments** (list of str) - Instrument names that will be extracted from GT instruments directory **Note**: These instruments are in instrument order, not in voice order! Multiple voices may use the same instrument, or multiple instruments may be on a voice. The instrument numbers are assigned in the order instruments are processed on conversion to RChirp. """ if rchirp_song.cts_type() != 'RChirp': raise Exception("Error: GoatTracker to_bin() only supports rchirp so far") self.set_options(**kwargs) self.append_instruments_to_rchirp(rchirp_song) parsed_gt = GTSong() parsed_gt.export_rchirp_to_parsed_gt( rchirp_song, self.get_option('end_with_repeat', False), self.get_option('max_pattern_len', DEFAULT_MAX_PAT_LEN)) return parsed_gt.export_parsed_gt_to_gt_binary()
[docs] def to_file(self, rchirp_song, filename, **kwargs): """ Convert and save an RChirpSong as a GoatTracker sng file :param rchirp_song: rchirp data :type rchirp_song: RChirpSong :param filename: output path and file name :type filename: str :keyword options: see `to_bin()` """ with open(filename, 'wb') as f: f.write(self.to_bin(rchirp_song, **kwargs))
[docs] def to_rchirp(self, filename, **kwargs): """ Import a GoatTracker sng file to RChirp :param filename: File name of .sng file :type filename: str :return: rchirp song :rtype: RChirpSong :keyword options: * **subtune** (int) - The subtune numer to import. Defaults to 0 * **arch** (str) - architecture string. Must be one defined in constants.py """ self.set_options(**kwargs) subtune = int(self.get_option('subtune', 0)) arch = self.get_option('arch', constants.DEFAULT_ARCH) rchirp_song = import_sng_file_to_rchirp(filename, subtune_number=subtune) rchirp_song.arch = arch return rchirp_song
def append_instruments_to_rchirp(self, rchirp_song): for instrument in list(self.get_option('instruments')): add_gt_instrument_to_rchirp(rchirp_song, instrument)
@dataclass class GtHeader: id: str = GT_FILE_HEADER song_name: str = '' author_name: str = '' copyright: str = '' num_subtunes: int = 0 def to_bytes(self): """ Converts header information into GT bytes. :return: bytes that represet the header fields :rtype: bytes """ result = bytearray() result += GT_FILE_HEADER result += pad_or_truncate(self.song_name, 32) result += pad_or_truncate(self.author_name, 32) result += pad_or_truncate(self.copyright, 32) result.append(self.num_subtunes) return result def __eq__(self, other): return self.to_bytes() == other.to_bytes() @dataclass class GtPatternRow: note_data: int = GT_REST instr_num: int = 0 command: int = 0 command_data: int = 0 def to_bytes(self): """ Converts a pattern row into GT bytes. :return: bytes that represet the pattern row :rtype: bytes """ if self.note_data is not None \ and not (GT_NOTE_OFFSET <= self.note_data <= GT_MAX_NOTE_VALUE) \ and self.note_data != GT_PAT_END: raise ChiptuneSAKValueError(f'Error: Illegal GT note value number: {self.note_data:02X}') if self.note_data is None: self.note_data = GT_REST else: if self.instr_num is None: raise ChiptuneSAKContentError("Error: instrument number is None") return bytes([self.note_data, self.instr_num, self.command, self.command_data]) PATTERN_END_ROW = GtPatternRow(note_data=GT_PAT_END) PATTERN_EMPTY_ROW = GtPatternRow(note_data=GT_REST) @dataclass class GtInstrument: """ Holds the parsed values from the 25-byte instrument data Note: the wave, pulse, filter, and speed table pointers are 1-based indexing. 0 is reserved to mean "not pointing to anything". However, the table bytes to which they point are 0-based, except for in the GoatTracker GUI where they're displayed as 1-based. """ instr_num: int = 0 attack_decay: int = 0 sustain_release: int = 0 wave_ptr: int = 0 pulse_ptr: int = 0 filter_ptr: int = 0 vib_speedtable_ptr: int = 0 vib_delay: int = 0 gateoff_timer: int = 0x02 hard_restart_1st_frame_wave: int = 0x09 inst_name: str = '' def to_bytes(self): """ Converts an instrument instance into GT bytes. :return: bytes that represet the instrument :rtype: bytes """ result = bytearray() result += bytes([self.attack_decay, self.sustain_release, self.wave_ptr, self.pulse_ptr, self.filter_ptr, self.vib_speedtable_ptr, self.vib_delay, self.gateoff_timer, self.hard_restart_1st_frame_wave]) result += pad_or_truncate(self.inst_name, 16) return result def __eq__(self, other): return self.to_bytes() == other.to_bytes() @classmethod def from_bytes(cls, instr_num, bytes, starting_index=0): """ Constructor that builds instrument (not supporting tables) from GT bytes :param instr_num: The GTSong instrument number :type instr_num: int :param bytes: Raw GT bytes :type bytes: bytes :param starting_index: starting index in bytes from which to start parsing, defaults to 0 :type starting_index: int, optional :return: new GtInstrument instance :rtype: GtInstrument """ if starting_index + GT_INSTR_BYTE_LEN - 1 > len(bytes): raise ChiptuneSAKValueError("Error: index out of range when instantiating GTInstrument") result = cls() result.instr_num = instr_num result.attack_decay = bytes[starting_index + 0] result.sustain_release = bytes[starting_index + 1] result.wave_ptr = bytes[starting_index + 2] result.pulse_ptr = bytes[starting_index + 3] result.filter_ptr = bytes[starting_index + 4] result.vib_speedtable_ptr = bytes[starting_index + 5] result.vib_delay = bytes[starting_index + 6] result.gateoff_timer = bytes[starting_index + 7] result.hard_restart_1st_frame_wave = bytes[starting_index + 8] result.inst_name = get_chars(bytes[starting_index + 9: starting_index + GT_INSTR_BYTE_LEN]) return result @dataclass class GtTable: row_cnt: int = 0 left_col: bytes = b'' right_col: bytes = b'' def append_table(self, a_table): """ Extend this table with another :param a_table: A GtTable instance of one of the four GT table types :type a_table: GtTable """ self.row_cnt += a_table.row_cnt if self.row_cnt >= GT_MAX_TABLE_LEN: raise ChiptuneSAKValueError("Error: max goattracker table size exceeded") self.left_col += a_table.left_col self.right_col += a_table.right_col def to_bytes(self): """ Converts a table into GT bytes. :return: bytes that represet the table :rtype: bytes """ result = bytearray() result.append(self.row_cnt) result += self.left_col result += self.right_col return result @classmethod def from_bytes(cls, bytes): """ Constructor that builds a table from GT bytes :param bytes: table in raw GT bytes format :type bytes: bytes :return: new GtTable instance :rtype: GtTable """ col_len = bytes[0] if len(bytes) != (col_len * 2) + 1: raise ChiptuneSAKValueError("Error: malformed table bytes in construction of GtTable instance") result = cls() result.row_cnt = col_len result.left_col = bytes[1:col_len + 1] result.right_col = bytes[col_len + 1:] return result def __eq__(self, other): return self.to_bytes() == other.to_bytes() def import_sng_file_to_rchirp(input_filename, subtune_number=0): """ Convert a GoatTracker sng file (normal or stereo) into an RChirp song instance :param input_filename: sng input path and filename :type input_filename: str :param subtune_number: the subtune number, defaults to 0 :type subtune_number: int, optional :return: An RChirp song for the subtune :rtype: RChirpSong """ if not input_filename.lower().endswith('.sng'): raise ChiptuneSAKIOError('Error: Expecting input filename that ends in ".sng"') if not path.exists(input_filename): raise ChiptuneSAKIOError('Cannot find "%s"' % input_filename) parsed_gt = GTSong() parsed_gt.import_sng_file_to_parsed_gt(input_filename) max_subtune_number = len(parsed_gt.subtune_orderlists) - 1 if subtune_number < 0: raise ChiptuneSAKValueError('Error: subtune_number must be >= 0') if subtune_number > max_subtune_number: raise ChiptuneSAKValueError('Error: subtune_number must be <= %d' % max_subtune_number) rchirp = parsed_gt.import_parsed_gt_to_rchirp(subtune_number) return rchirp def pattern_note_to_midi_note(pattern_note_byte, octave_offset=0): """ Convert pattern note byte value into midi note value :param pattern_note_byte: GT note value :type pattern_note_byte: int :param octave_offset: Should always be zero unless some weird midi offset exists :type octave_offset: int :return: Midi note number :rtype: int """ midi_note = pattern_note_byte - (GT_NOTE_OFFSET - constants.C0_MIDI_NUM) + (octave_offset * 12) if not (0 <= midi_note < 128): raise ChiptuneSAKValueError(f"Error: illegal midi note value {midi_note} from gt {pattern_note_byte}") return midi_note def get_table(an_index, file_bytes): """ Used to parse wave, pulse, filter, and speed tables from raw GT bytes :param an_index: index for where to start parsing the file_bytes :type an_index: int :param file_bytes: bytes containing table data :type file_bytes: bytes :return: A new GtTable instance :rtype: GtTable """ rows = file_bytes[an_index] # no point in checking rows > GT_MAX_TABLE_LEN, since GT_MAX_TABLE_LEN is a $FF (max byte val) an_index += 1 left_entries = file_bytes[an_index:an_index + rows] an_index += rows right_entries = file_bytes[an_index:an_index + rows] return GtTable(row_cnt=rows, left_col=left_entries, right_col=right_entries) def pad_or_truncate(to_pad, length): """ Truncate or pad (with zeros) a GT text field :param to_pad: text to pad :type to_pad: either string or bytes :param length: grow or shrink input to this length ("Procrustean bed") :type length: int :return: processed text field :rtype: """ if isinstance(to_pad, str): to_pad = to_pad.encode('latin-1') return to_pad.ljust(length, b'\0')[0:length] def get_chars(in_bytes, trim_nulls=True): """ Convert zero-padded GT text field into string :param in_bytes: gt text field in bytes :type in_bytes: bytes :param trim_nulls: if true, trim off the zero-padding, defaults to True :type trim_nulls: bool, optional :return: String conversion :rtype: str """ result = in_bytes.decode('Latin-1') if trim_nulls: result = result.strip('\0') # no interpretation, preserve encoding return result def get_ins_filenames(): """ Get the .ins GoatTracker instrument filenames :return: list of filenames :rtype: list """ dir = constants.project_to_absolute_path(DEFAULT_INSTR_PATH) ins_files = [f for f in listdir(dir) if isfile(join(dir, f)) and f[-4:] == '.ins'] return ins_files def create_gt_metadata_if_missing(rchirp_song): """ Create empty GoatTracker metadata structions on rchirp if they're not present :param rchirp_song: an rchirp song instance :type rchirp_song: RChirpSong """ extensions = rchirp_song.metadata.extensions if "gt.instruments" not in extensions: extensions["gt.instruments"] = bytearray() # stub in tables with a single entry if "gt.wave_table" not in extensions: extensions["gt.wave_table"] = bytearray(b'\x00') if "gt.pulse_table" not in extensions: extensions["gt.pulse_table"] = bytearray(b'\x00') if "gt.filter_table" not in extensions: extensions["gt.filter_table"] = bytearray(b'\x00') if "gt.speed_table" not in extensions: extensions["gt.speed_table"] = bytearray(b'\x00') def instrument_appender( gt_inst_name, new_instr_num, in_wave_table, in_pulse_table, in_filter_table, in_speed_table, path=DEFAULT_INSTR_PATH ): """ Load the named instrument's ins file and generate updated wavetables """ ins_bytes = read_binary_file(constants.project_to_absolute_path(path + gt_inst_name + '.ins')) if ins_bytes[0:4] != b'GTI5': raise ChiptuneSAKValueError("Error: Invalid instrument file structure") file_index = 4 # Strange, the wave/pulse/filter/vib_speedtable pointers come in with unrelocated values, # (seems like they'd be set to zero or something) but that's ok, since they'll be relocated # later in this method an_instrument = GtInstrument.from_bytes(new_instr_num, ins_bytes, file_index) file_index += GT_INSTR_BYTE_LEN tables = [] for _ in range(4): a_table = get_table(file_index, ins_bytes) tables.append(a_table) file_index += a_table.row_cnt * 2 + 1 # FUTURE: processing updates to these four tables and table pointers could be # loop-generalized instead of processed separately if tables[0].row_cnt == 0: an_instrument.wave_ptr = 0 else: an_instrument.wave_ptr = in_wave_table.row_cnt + 1 in_wave_table.append_table(tables[0]) if tables[1].row_cnt == 0: an_instrument.pulse_ptr = 0 else: an_instrument.pulse_ptr = in_pulse_table.row_cnt + 1 in_pulse_table.append_table(tables[1]) if tables[2].row_cnt == 0: an_instrument.filter_ptr = 0 else: an_instrument.filter_ptr = in_filter_table.row_cnt + 1 in_filter_table.append_table(tables[2]) if tables[3].row_cnt == 0: an_instrument.vib_speedtable_ptr = 0 else: an_instrument.vib_speedtable_ptr = in_speed_table.row_cnt + 1 in_speed_table.append_table(tables[3]) return (an_instrument, in_wave_table, in_pulse_table, in_filter_table, in_speed_table) # load GoatTracker v2 instrument (.ins file) and append to song def add_gt_instrument_to_rchirp(rchirp_song, gt_inst_name, path=DEFAULT_INSTR_PATH): """ Appends a instrument binary to the RChirp metadata extensions. Taking an "append-only" approach to adding instruments to RChirp metadata for the following reasons: 1) If RChirp instruments were imported from a sng file, the four supporting tables can have code that is shared (entangled) between instruments. It would be more work to allow mutations (delete, move, etc.) on individual instruments (unlike SID-Wizard which keeps each instrument data completely separate). 2) In practice, GoatTracker composers tend to use instrument numbers in order, so an append-only approach is flexible enough. :param rchirp_song: An RChirpSong instance :type rchirp_song: RChirpSong :param gt_inst_name: Filename of GoatTracker instrument (without path or .ins extension) :type gt_inst_name: str :param path: path from project root, defaults to 'res/gtInstruments/' :type path: str, optional """ create_gt_metadata_if_missing(rchirp_song) extensions = rchirp_song.metadata.extensions new_instr_num = (len(extensions["gt.instruments"]) // GT_INSTR_BYTE_LEN) + 1 (instr, wt, pt, ft, st) = instrument_appender( gt_inst_name, new_instr_num, GtTable.from_bytes(extensions["gt.wave_table"]), GtTable.from_bytes(extensions["gt.pulse_table"]), GtTable.from_bytes(extensions["gt.filter_table"]), GtTable.from_bytes(extensions["gt.speed_table"])) # append instrument extensions["gt.instruments"] += instr.to_bytes() # assign updated wavetables extensions["gt.wave_table"] = wt.to_bytes() extensions["gt.pulse_table"] = pt.to_bytes() extensions["gt.filter_table"] = ft.to_bytes() extensions["gt.speed_table"] = st.to_bytes() class GTSong: """ Contains parsed version of .sng file binary data. """ def __init__(self): self.headers = GtHeader() #: goattracker file headers self.num_channels = 3 #: 3 or 6 voices self.subtune_orderlists = [[[], [], []]] #: Nested lists: Subtunes->channels->orderlist self.instruments = [] #: list of GtInstrument instances self.wave_table = GtTable() #: wave table self.pulse_table = GtTable() #: pulse table self.filter_table = GtTable() #: filter table self.speed_table = GtTable() #: speed table self.patterns = [[]] #: Nested lists: patterns->GtPatternRow instances def is_stereo(self): """ Determines if this is stereo GoatTracker :return: True if stereo, False if not :rtype: bool """ return self.num_channels >= 4 def get_instruments_bytes(self): """ Create native GT bytes for all the instruments (not including supporting tables) :return: byte represtation of all instruments :rtype: bytes """ result = bytearray() for i in range(1, len(self.instruments)): result += self.instruments[i].to_bytes() return result def set_instruments_from_bytes(self, bytes): """ Set GTSong's instruments from raw bytes (not including supporting tables) :param bytes: bytes containing instruments' data :type bytes: bytes """ if len(bytes) % GT_INSTR_BYTE_LEN != 0: raise ChiptuneSAKValueError("Error: malformed instrument bytes") instruments = [GtInstrument()] # start with empty instrument number 0 for i in range(len(bytes) // GT_INSTR_BYTE_LEN): an_instrument = GtInstrument.from_bytes(i + 1, bytes, i * GT_INSTR_BYTE_LEN) instruments.append(an_instrument) self.instruments = instruments def get_orderlist(self, an_index, file_bytes): """ Parse out an orderlist from file_bytes starting at an_index Note: orderlist length byte is length -1 e.g., orderlist CHN1: "00 04 07 0d 09 RST00" in file as 06 00 04 07 0d 09 FF 00 length-1 (06), followed by 7 bytes :param an_index: index in file_bytes from which to start parsing :type an_index: int :param file_bytes: bytes containing orderlist :type file_bytes: bytes :return: an orderlist :rtype: bytes """ length = file_bytes[an_index] + 1 # add one for restart an_index += 1 orderlist = file_bytes[an_index:an_index + length] an_index += length # check that next-to-last byte is $FF if file_bytes[an_index - 2] != 255: raise ChiptuneSAKContentError( "Error: Did not find expected $FF RST endmark in channel's orderlist") return orderlist def is_2sid(self, index_at_start_of_orderlists, sng_bytes): """ Heuristic to determine if .sng binary is 1SID or 2SID (aka "stereo") :param index_at_start_of_orderlists: index of start of orderlists in sng_bytes :type index_at_start_of_orderlists: int :param sng_bytes: bytes containing orderlists :type sng_bytes: bytes :return: True if 2SID, False if 1SID :rtype: bool """ expected_num_orderlists_for_3sid = self.headers.num_subtunes * 3 expected_num_orderlists_for_6sid = expected_num_orderlists_for_3sid * 2 file_index = index_at_start_of_orderlists orderlist_count = 0 while True: index_of_ff = sng_bytes[file_index] # get length (minus 1) of orderlist for voice if sng_bytes[file_index + index_of_ff] != 0xff: # if orderlist, will be $FF break orderlist_count += 1 file_index += index_of_ff + 2 # account for the byte after the 0xff if orderlist_count == expected_num_orderlists_for_3sid: return False if orderlist_count == expected_num_orderlists_for_6sid: return True raise ChiptuneSAKContentError("Error: found %d orderlists (expected %d or %d)" \ % (orderlist_count, expected_num_orderlists_for_3sid, expected_num_orderlists_for_6sid)) def import_sng_file_to_parsed_gt(self, input_filename): """ Parse a goat tracker '.sng' file and put it into a GTSong instance. Supports 1SID and 2SID (stereo) goattracker '.sng' files. :param input_filename: Filename for input .sng file :type input_filename: str """ with open(input_filename, 'rb') as f: sng_bytes = f.read() self.import_sng_binary_to_parsed_gt(sng_bytes) def import_sng_binary_to_parsed_gt(self, sng_bytes): """ Parse a goat tracker '.sng' binary and put it into a GTSong instance. Supports 1SID and 2SID (stereo) goattracker '.sng' file binaries. :param sng_bytes: Binary contents of a sng file :type sng_bytes: bytes """ header = GtHeader() header.id = sng_bytes[0:4] if header.id != GT_FILE_HEADER: raise ChiptuneSAKContentError("Error: Did not find magic header") header.song_name = get_chars(sng_bytes[4:36]) header.author_name = get_chars(sng_bytes[36:68]) header.copyright = get_chars(sng_bytes[68:100]) header.num_subtunes = sng_bytes[100] if header.num_subtunes > GT_MAX_SUBTUNES_PER_SONG: raise ChiptuneSAKContentError("Error: too many subtunes") file_index = 101 self.headers = header # From goattracker documentation: (note: doesn't account for stereo sid) # 6.1.2 ChirpSong orderlists # --------------------- # The orderlist structure repeats first for channels 1,2,3 of first subtune, # then for channels 1,2,3 of second subtune etc., until all subtunes # have been gone thru. # # Offset Size Description # +0 byte Length of this channel's orderlist n, not counting restart pos. # +1 n+1 The orderlist data: # Values $00-$CF are pattern numbers # Values $D0-$DF are repeat commands # Values $E0-$FE are transpose commands # Value $FF is the RST endmark, followed by a byte that indicates # the restart position if self.is_2sid(file_index, sng_bytes): # check if this is a "stereo" sid self.num_channels = 6 subtune_orderlists = [] for _ in range(header.num_subtunes): channels_order_list = [] for i in range(self.num_channels): channel_order_list = self.get_orderlist(file_index, sng_bytes) file_index += len(channel_order_list) + 1 channels_order_list.append(channel_order_list) subtune_orderlists.append(channels_order_list) self.subtune_orderlists = subtune_orderlists # From goattracker documentation: # 6.1.3 Instruments # ----------------- # Offset Size Description # +0 byte Amount of instruments n # # Then, this structure repeats n times for each instrument. Instrument 0 (the # empty instrument) is not stored. # # Offset Size Description # +0 byte Attack/Decay # +1 byte Sustain/Release # +2 byte Wavepointer # +3 byte Pulsepointer # +4 byte Filterpointer # +5 byte Vibrato param. (speedtable pointer) # +6 byte Vibraro delay # +7 byte Gateoff timer # +8 byte Hard restart/1st frame waveform # +9 16 Instrument name instruments = [GtInstrument()] # start with empty instrument number 0 inst_count = sng_bytes[file_index] # doesn't include the NOP instrument 0 file_index += 1 for i in range(inst_count): an_instrument = GtInstrument.from_bytes(i + 1, sng_bytes, file_index) instruments.append(an_instrument) file_index += GT_INSTR_BYTE_LEN self.instruments = instruments # From goattracker documentation: # 6.1.4 Tables # ------------ # This structure repeats for each of the 4 tables (wavetable, pulsetable, # filtertable, speedtable). # # Offset Size Description # +0 byte Amount n of rows in the table # +1 n Left side of the table # +1+n n Right side of the table tables = [] for i in range(4): a_table = get_table(file_index, sng_bytes) tables.append(a_table) file_index += a_table.row_cnt * 2 + 1 (self.wave_table, self.pulse_table, self.filter_table, self.speed_table) = tables # From goattracker documentation: # 6.1.5 Patterns header # --------------------- # Offset Size Description # +0 byte Number of patterns n # # 6.1.6 Patterns # -------------- # Repeat n times, starting from pattern number 0. # # Offset Size Description # +0 byte Length of pattern in rows m # +1 m*4 Groups of 4 bytes for each row of the pattern: # 1st byte: Notenumber # Values $60-$BC are the notes C-0 - G#7 # Value $BD is rest # Value $BE is keyoff # Value $BF is keyon # Value $FF is pattern end # 2nd byte: Instrument number ($00-$3F) # 3rd byte: Command ($00-$0F) # 4th byte: Command databyte num_patterns = sng_bytes[file_index] file_index += 1 patterns = [] for pattern_num in range(num_patterns): a_pattern = [] num_rows = sng_bytes[file_index] if num_rows > GT_MAX_ROWS_PER_PATTERN: raise ChiptuneSAKContentError("Error: Too many rows in a pattern") file_index += 1 for row_num in range(num_rows): a_row = GtPatternRow( note_data=sng_bytes[file_index], instr_num=sng_bytes[file_index + 1], command=sng_bytes[file_index + 2], command_data=sng_bytes[file_index + 3], ) if not ((GT_NOTE_OFFSET <= a_row.note_data <= GT_MAX_NOTE_VALUE) or a_row.note_data == GT_PAT_END): raise ChiptuneSAKContentError("Error: unexpected note data value") if a_row.instr_num > GT_MAX_INSTR_PER_SONG: raise ChiptuneSAKValueError("Error: instrument number out of range") if a_row.command > 0x0F: raise ChiptuneSAKValueError("Error: command number out of range") file_index += 4 a_pattern.append(a_row) patterns.append(a_pattern) self.patterns = patterns if file_index != len(sng_bytes): raise ChiptuneSAKContentError("Error: bytes parsed didn't match file bytes length") def midi_note_to_pattern_note(self, midi_note, octave_offset=0): """ Convert midi note value to pattern note value :param midi_note: midi note number (Note: Lowest midi note allowed = 12 (C0_MIDI_NUM) :type midi_note: int :param octave_offset: Should always be zero unless some weird midi offset exists :type octave_offset: int :return: GT note value :rtype: int """ gt_note_value = midi_note + (GT_NOTE_OFFSET - constants.C0_MIDI_NUM) + (-1 * octave_offset * 12) if not (GT_NOTE_OFFSET <= gt_note_value <= GT_MAX_NOTE_VALUE): raise ChiptuneSAKValueError(f"Error: illegal gt note data value {gt_note_value} from midi {midi_note}") return gt_note_value def make_orderlist_entry(self, pattern_number, transposition, repeats, prev_transposition): """ Makes orderlist entries from a pattern number, a transposition, and a number of repeats. :param pattern_number: pattern number :type pattern_number: int :param transposition: transposition in semitones :type transposition: int :param repeats: Number of times to repeat :type repeats: int :param prev_transposition: Previous transposition :type prev_transposition: int :return: list of orderlist command :rtype: list of int """ retval = [] # Only insert transposition (absolute) when it changes if transposition == prev_transposition: transposition = None elif -15 <= transposition <= 14: # Check that transposition is in allowed range transposition += 0xF0 # offset for transpositions else: # Instead of dying, fix transpositions by doing octave offsets until it is within range. while transposition > 14: transposition -= 12 while transposition < -15: transposition += 12 if not (-15 <= transposition <= 14): raise ChiptuneSAKValueError("Error: bad transposition = %d" % transposition) transposition += 0xF0 # Longest possible repeat is 16, so generate as many of those as needed while repeats >= 16: if transposition is not None: retval.append(transposition) # If no transposition, leave it off. transposition = None # Only add transposition once retval.append(0xD0) # Repeat 16 times retval.append(pattern_number) repeats -= 16 # Now do the last one if there are any left (usually this is the only part accessed) if repeats > 0: if transposition is not None: retval.append(transposition) if repeats != 1: # If only one time, no need to put anything in. retval.append(repeats - 1 + 0xD0) # Repeat N times retval.append(pattern_number) if not all(0 <= x <= 0xFF for x in retval): raise ChiptuneSAKValueError("Error: Byte value error in orderlist") return retval def export_parsed_gt_to_gt_binary(self): """ Convert parsed_gt into a goattracker .sng binary. :return: a GoatTracker sng file binary :rtype: bytes """ gt_binary = bytearray() gt_binary += self.headers.to_bytes() for subtune in self.subtune_orderlists: for channel_orderlist in subtune: # orderlist length minus 1, strange but true gt_binary.append(len(channel_orderlist) - 1) gt_binary += bytes(channel_orderlist) # number of instruments (not counting NOP instrument number 0) gt_binary.append(len(self.instruments) - 1) gt_binary += self.get_instruments_bytes() gt_binary += self.wave_table.to_bytes() gt_binary += self.pulse_table.to_bytes() gt_binary += self.filter_table.to_bytes() gt_binary += self.speed_table.to_bytes() gt_binary.append(len(self.patterns)) # number of patterns for pattern in self.patterns: gt_binary.append(len(pattern)) for row in pattern: gt_binary += row.to_bytes() return gt_binary def import_parsed_gt_to_rchirp(self, subtune_num=0): """ Convert the parsed GoatTracker file into rchirp In GoatTracker any channel can change all the channels' tempos or just its own tempo at any time. This is too complex for RChirp representation. So this code simulates the playback on a frame-by-frame (aka jiffy) basis, "unrolling" the tempos. What's left is only per-channel tempo changes, which can be different from the other channels (an important tracker feature worth preserving). The patterns and voice orderlists found in the original GoatTracker song cannot be mapped 1-to-1 with rchirp.patterns and rchirp.voices[].orderlist without all of this complex processing. However, we expect many C64 game music engines to have patterns and orderlists that can be directly mapped without much effort. :param subtune_num: The subtune number to convert to rchirp, defaults to 0 :type subtune_num: int, optional :return: rchirp song instance :rtype: RChirpSong """ rchirp_song = rchirp.RChirpSong() rchirp_song.metadata.name = self.headers.song_name rchirp_song.metadata.composer = self.headers.author_name rchirp_song.metadata.copyright = self.headers.copyright # init state holders for each channel to use as we step through each tick (aka frame) channels_state = \ [GtChannelState(i + 1, self.subtune_orderlists[subtune_num][i]) for i in range(self.num_channels)] rchirp_song.voices = [rchirp.RChirpVoice(rchirp_song) for i in range(self.num_channels)] # TODO: Make track assignment to SID groupings not hardcoded if self.is_stereo: rchirp_song.voice_groups = [(1, 2, 3), (4, 5, 6)] else: rchirp_song.voice_groups = [(1, 2, 3)] # Handle the rarely-used sneaky default global tempo setting # from docs: # For very optimized songdata & player you can refrain from using any pattern # commands and rely on the instruments' step-programming. Even in this case, you # can set song startup default tempo with the Attack/Decay parameter of the last # instrument (63/0x3F), if you otherwise leave this instrument unused. # TODO: This code block is untested if len(self.instruments) == GT_MAX_INSTR_PER_SONG: ad = self.instruments[GT_MAX_INSTR_PER_SONG - 1].attack_decay if 0x03 <= ad <= 0x7F: for cs in channels_state: cs.curr_tempo = ad global_tick = -1 # Step through each tick (frame). For each tick, evaluate the state of each channel. # Continue until all channels have hit the end of their respective orderlists while not all(cs.restarted for cs in channels_state): # When not using multispeed, tempo = ticks per row = screen refreshes per row. # 'Ticks' on C64 are also 'frames' or 'jiffies'. Each tick in PAL is around 20ms, # and ~16.7‬ms on NTSC. # (in contrast, for a multispeed of 2, there would be two music updates per frame) global_tick += 1 global_tempo_change = None for i, cs in enumerate(channels_state): # Either reduce time left on this row, or get the next new goattracker data row gt_row = cs.next_tick(self) if gt_row is None: # if we didn't advance to a new row... continue rc_row = rchirp.RChirpRow() rc_row.milliframe_num = global_tick * 1000 rc_row.milliframe_len = cs.curr_tempo * 1000 # KeyOff (only recorded if there's a curr_note defined) if cs.row_has_key_off: rc_row.note_num = cs.curr_note rc_row.gate = False # KeyOn (only recorded if there's a curr_note defined) if cs.row_has_key_on: rc_row.note_num = cs.curr_note rc_row.instr_num = gt_row.instr_num # Why not... rc_row.gate = True # if note_data is an actual note, then cs.curr_note has been updated elif cs.row_has_note: rc_row.note_num = cs.curr_note rc_row.instr_num = gt_row.instr_num rc_row.gate = True # process tempo changes # Note: local_tempo_update and global_tempo_update init to None when new row fetched if cs.local_tempo_update is not None: # Apply local (single channel) tempo change if cs.local_tempo_update >= 2: cs.curr_funktable_index = None cs.curr_tempo = cs.local_tempo_update else: # it's an index to a funktable tempo cs.curr_funktable_index = cs.local_tempo_update # convert into a normal tempo change cs.curr_tempo = GtChannelState.funktable[cs.curr_funktable_index] rc_row.milliframe_len = cs.curr_tempo * 1000 # this channel signals a global tempo change that will affect all the channels # once out of this per-channel loop elif cs.global_tempo_update is not None: global_tempo_change = cs.global_tempo_update rchirp_song.voices[i].append_row(rc_row) # By this point, we've passed through all channels for this particular tick # If more than one channel made a tempo change, the global tempo change on the highest # voice/channel number wins (consistent with goattracker behavior) if global_tempo_change is not None: for j, cs in enumerate(channels_state): # Time to apply the global changes: if global_tempo_change >= 2: cs.curr_funktable_index = None # funk tempo mode off new_tempo = global_tempo_change else: # it's an index to a funktable tempo cs.curr_funktable_index = global_tempo_change # stateful funky tracking # convert into a normal tempo change new_tempo = GtChannelState.funktable[cs.curr_funktable_index] current_rc_row = rchirp_song.voices[j].last_row # If row state is in progress, leave its remaining ticks alone. # But if it's the very start of a new row, then override with the new global tempo if cs.first_tick_of_row: cs.row_ticks_left = new_tempo current_rc_row.milliframe_len = new_tempo * 1000 cs.curr_tempo = new_tempo # Create note offs once all channels have hit their orderlist restart one or more times # Ok, cheesy hack here. The loop above repeats until all tracks have had a chance to restart, # but it allows each voice to load in one row after that point. Taking advantage of that, we # modify that row with note off events, looking backwards to previous rows to see what the last # note was to use in the note off events. for i, cs in enumerate(channels_state): rows = rchirp_song.voices[i].rows reversed_index = list(reversed(list(rows.keys()))) for seek_index in reversed_index[1:]: # skip largest row num, and work backwards if rows[seek_index].note_num is not None: rows[reversed_index[0]].note_num = rows[seek_index].note_num rows[reversed_index[0]].gate = False # gate off break rchirp_song.set_row_delta_values() rchirp_song.metadata.extensions["gt.instruments"] = self.get_instruments_bytes() rchirp_song.metadata.extensions["gt.wave_table"] = self.wave_table.to_bytes() rchirp_song.metadata.extensions["gt.pulse_table"] = self.pulse_table.to_bytes() rchirp_song.metadata.extensions["gt.filter_table"] = self.filter_table.to_bytes() rchirp_song.metadata.extensions["gt.speed_table"] = self.speed_table.to_bytes() # Before returning the rchirp song, might as well make use of our test cases here rchirp_song.integrity_check() # Will throw assertions if there are any problems assert rchirp_song.is_contiguous(), "Error: rchirp representation should not be sparse" return rchirp_song def add_gt_instrument_to_parsed_gt(self, gt_inst_name, path=DEFAULT_INSTR_PATH): """ Append instrument to parsed gt instance. Recommend using add_gt_instrument_to_rchirp() when adding instruments outside of this module (not adding instruments directly to GTSong). :param gt_inst_name: Filename of GoatTracker instrument (without path or .ins extension) :type gt_inst_name: str :param path: path from project root, defaults to 'res/gtInstruments/' :type path: str, optional """ new_instr_num = len(self.instruments) # no +1 here (instr, self.wave_table, self.pulse_table, self.filter_table, self.speed_table) = \ instrument_appender(gt_inst_name, new_instr_num, self.wave_table, self.pulse_table, self.filter_table, self.speed_table) self.instruments.append(instr) def export_rchirp_to_parsed_gt(self, rchirp_song, end_with_repeat=False, max_pattern_len=DEFAULT_MAX_PAT_LEN): """ Populate GTSong instance from RChirp data. Instrument assignments: Before calling this method, the rchirp can have GoatTracker instruments appended to it using add_gt_instrument_to_rchirp(). Any instrument numbers found in the RChirp for which there is no corresponding instrument in the rchirp_song.metadata.extensions["gt.instruments"] will cause this code to load "SimpleTriangle" for that instrument number. :param rchirp_song: The rchirp song to convert :type rchirp_song: RChirpSong :param end_with_repeat: True if song should repeat when finished, defaults to False :type end_with_repeat: bool, optional :param max_pattern_len: If creating orderlist/patterns, sets the maximum pattern lengths :type max_pattern_len: int, optional """ TRUNCATE_IF_TOO_BIG = True self.__init__() # clear out anything that might be in this GTSong instance headers = GtHeader( song_name=rchirp_song.metadata.name[:32], author_name=rchirp_song.metadata.composer[:32], copyright=rchirp_song.metadata.copyright[:32], num_subtunes=1) self.headers = headers is_stereo = len(rchirp_song.voices) >= 4 if len(rchirp_song.voices) > 6: raise ChiptuneSAKContentError("Error: Stereo SID can only support up to 6 voices") if is_stereo: num_channels = 6 else: num_channels = 3 self.num_channels = num_channels patterns = [] # can be shared across all channels orderlists = [[] for _ in range(num_channels)] # Note: this is bad: [[]] * len(tracknums) instrument_nums_seen = set() too_many_patterns = False # When lowering RChirp towards a native format, if orderlists/patterns are present, # those should be used. These could have come about by chiptuneSAK compression (aka # pattern discovery), or from having created RChirp from a source that uses patterns. # If no orderlists/patterns are present, the lowerer will have to create them. if rchirp_song.has_patterns(): # Convert the patterns to goattracker patterns for ip, p in enumerate(rchirp_song.patterns): pattern = [] # initialize new empty pattern for r in p.rows: gt_row = GtPatternRow() # make a new empty pattern row if r.gate: gt_row.note_data = self.midi_note_to_pattern_note(r.note_num) gt_row.instr_num = r.instr_num instrument_nums_seen.add(r.instr_num) elif r.gate is False: # if ending a note ('false' check because tri-state) gt_row.note_data = GT_KEY_OFF gt_row.instr_num = r.instr_num if r.new_milliframe_tempo is not None: gt_row.command = GT_TEMPO_CHNG_CMD # insert local channel tempo change gt_row.command_data = r.new_milliframe_tempo // 1000 + 0x80 pattern.append(gt_row) pattern.append(PATTERN_END_ROW) # finish with end row marker patterns.append(pattern) for i, v in enumerate(rchirp_song.voices): prev_transposition = 0 # Start out each voice with default transposition of 0 for entry in v.orderlist: ol_entry = self.make_orderlist_entry( entry.pattern_num, entry.transposition, entry.repeats, prev_transposition, ) orderlists[i].extend(ol_entry) prev_transposition = entry.transposition # Must create our own orderlist else: curr_pattern_num = 0 # for each channel, get its rows, and create patterns, adding them to the # channel's orderlist for i, rchirp_voice in enumerate(rchirp_song.voices): rchirp_rows = rchirp_voice.rows pattern_row_index = 0 pattern = [] # create a new, empty pattern max_row = max(rchirp_rows) prev_instrument = 1 # Iterate across row num span (inclusive). Would normally iterated over # sorted rchirp_rows dict keys, but since rchirp is allowed to be sparse # we're being careful here to insert an empty row for missing row num keys for j in range(max_row + 1): # Convert each rchirp_row into the gt_row (used for binary gt row representation) if j in rchirp_rows: rchirp_row = rchirp_rows[j] gt_row = GtPatternRow() if rchirp_row.gate: # if starting a note gt_row.note_data = self.midi_note_to_pattern_note(rchirp_row.note_num) if not (GT_NOTE_OFFSET <= gt_row.note_data <= GT_MAX_NOTE_VALUE): raise ChiptuneSAKValueError('Error: Illegal note number') if rchirp_row.new_instrument is not None: gt_row.instr_num = rchirp_row.new_instrument prev_instrument = rchirp_row.new_instrument instrument_nums_seen.add(rchirp_row.new_instrument) else: # unlike SID-Wizard which only asserts instrument changes (on any row), # goattracker asserts the current instrument with every note # (goattracker can assert instrument without note, but that's a NOP) gt_row.instr_num = prev_instrument elif rchirp_row.gate is False: # if ending a note ('false' check because tri-state) gt_row.note_data = GT_KEY_OFF if rchirp_row.new_milliframe_tempo is not None: gt_row.command = GT_TEMPO_CHNG_CMD # insert local channel tempo change gt_row.command_data = rchirp_row.new_milliframe_tempo // 1000 + 0x80 pattern.append(gt_row) else: pattern.append(PATTERN_EMPTY_ROW) pattern_row_index += 1 # max_pattern_len notes: index 0 to len-1 for data, index len for 0xFF pattern end mark if pattern_row_index == max_pattern_len: # if pattern is full pattern.append(PATTERN_END_ROW) # finish with end row marker patterns.append(pattern) orderlists[i].append(curr_pattern_num) # append to orderlist for this channel curr_pattern_num += 1 if curr_pattern_num >= GT_MAX_PATTERNS_PER_SONG: too_many_patterns = True break pattern = [] pattern_row_index = 0 if too_many_patterns: break if len(pattern) > 0: # if there's a final partially-filled pattern, add it pattern.append(PATTERN_END_ROW) patterns.append(pattern) orderlists[i].append(curr_pattern_num) curr_pattern_num += 1 if curr_pattern_num >= GT_MAX_PATTERNS_PER_SONG: too_many_patterns = True if too_many_patterns: if TRUNCATE_IF_TOO_BIG: print("Warning: too much note data, truncated patterns") else: raise ChiptuneSAKContentError("Error: More than %d goattracker patterns created" % GT_MAX_PATTERNS_PER_SONG) # Usually, songs repeat. Each channel's orderlist ends with RST00, which means restart at the # 1st entry in that channel's pattern list (note: orderlist is normally full of pattern numbers, # but the number after RST is not a pattern number, but an index back into that channel's orderlist) # As far as I can tell, people create an infinite loop at the end when they don't want a song to # repeat, so that's what this code can do. # # end_with_repeat == False in no way implies that all tracks will restart at the same time # # Design note: Thought about moving the repeat-pattern injection (end_with_repeat) into a # GTSong-only method, but decided against it, since RChirp-related methods are where patterns # are created/modified. if not end_with_repeat and not too_many_patterns: # create a new empty pattern for all channels to loop on forever # and add to the end of each orderlist loop_pattern = [] loop_pattern.append(GtPatternRow(note_data=GT_KEY_OFF)) loop_pattern.append(PATTERN_END_ROW) patterns.append(loop_pattern) loop_pattern_num = len(patterns) - 1 for i in range(num_channels): orderlists[i].append(loop_pattern_num) # pattern caps all voices' orderlists for i in range(num_channels): orderlists[i].append(GT_OL_RST) # all patterns end with restart indicator if end_with_repeat: # if each voice starts completely over... orderlists[i].append(0) # index of start of channel order list else: orderlists[i].append(len(orderlists[i]) - 2) # index of the empty loop pattern self.patterns = patterns self.subtune_orderlists = [orderlists] # only one subtune, so nested in a pair of list brackets create_gt_metadata_if_missing(rchirp_song) extensions = rchirp_song.metadata.extensions # See if there's any instrument data to import from the RChirp if "gt.instruments" in extensions: self.set_instruments_from_bytes(extensions["gt.instruments"]) self.wave_table = GtTable.from_bytes(extensions["gt.wave_table"]) self.pulse_table = GtTable.from_bytes(extensions["gt.pulse_table"]) self.filter_table = GtTable.from_bytes(extensions["gt.filter_table"]) self.speed_table = GtTable.from_bytes(extensions["gt.speed_table"]) # special instrument number that can be used for global tempo settings (rarely seen): ignore = GT_MAX_INSTR_PER_SONG - 1 # find all instrument numbers for which an instrument binary is not already defined # (defined from importing from an sng and/or using add_gt_instrument_to_rchirp() ) rchirp_inst_count = len(rchirp_song.metadata.extensions["gt.instruments"]) unmapped_inst_nums = [x for x in instrument_nums_seen if x > rchirp_inst_count and x != ignore] # since we're in an instrument append-only world (at least for now), just append # simple triangle instrument up to the max unmatched instrument # This can create a lot of redundant instruments, e.g., for a seen set like 6, 3, 9, it will # create the Simple Triangle up to 9 times (slots 1 through 9). Currently, we don't think it's # the job of goat_tracker to map an arbitrary set of instrument numbers to a consecutive # list starting from 1 (e.g., 3->1, 6->2, 9->3) but perhaps later, that functionality will # exist here. if len(unmapped_inst_nums) > 0: for i in range(rchirp_inst_count, max(unmapped_inst_nums) + 1): self.add_gt_instrument_to_parsed_gt("SimpleTriangle") # Used when "running" the channels to convert them to note on/off events in time class GtChannelState: # The two funktable entries are shared by all channels using a funktempo, so we have it as a # class-side var. Note, this approach won't work if we want GtChannelState instances belonging # to and processing different songs at the same time (seems unlikely). # FUTURE: add instrument handling # FUTURE: ignoring multispeed considerations for now (would act as a simple multiplier for each) funktable = GT_DEFAULT_FUNKTEMPOS def __init__(self, voice_num, channel_orderlist): self.voice_num = voice_num self.orderlist_index = -1 # -1 = bootstrapping value only, None = stuck in loop with no patterns self.row_index = -1 # -1 = bootstrapping value only self.pat_remaining_plays = 1 # default is to play a pattern once self.row_ticks_left = 1 # required value when bootstrapping self.first_tick_of_row = False self.curr_transposition = 0 self.curr_note = None # converted to midi note number self.row_has_note = False # if True, curr_note is immediately set self.row_has_key_on = False # gate bit mask on, reasserting last played note (found in self.curr_note) self.row_has_key_off = False # gate bit mask off self.local_tempo_update = None self.global_tempo_update = None self.restarted = False # channel has encountered restart one or more times self.channel_orderlist = channel_orderlist # just this channel's orderlist from the subtune self.curr_funktable_index = None # None = no funk tempos, 0 or 1 indicates current funktable index self.curr_tempo = GT_DEFAULT_TEMPO # position atop first pattern in orderlist for channel self.__inc_orderlist_to_next_pattern() # Advance channel/voice by a tick. This will either: # 1) decrement a row's remaining ticks by one, or # 2) if the row's jiffies are spent, return the next row (if any) # Returns None if not returning a new row def next_tick(self, a_song): self.first_tick_of_row = False # If stuck in an orderlist loop that doesn't contain a pattern, then there's nothing to do if self.orderlist_index is None: return None self.row_ticks_left -= 1 # decrement ticks remaining in this row assert self.row_ticks_left >= 0, "Error: Can't have negative tick values" # if not advancing to a new row (0 ticks left), then we're done here if self.row_ticks_left > 0: return None new_row_duration = None self.inc_to_next_row(a_song.patterns) # finished last pattern row, advance to the next # get the current row in the current pattern from this channel's orderlist row = copy.deepcopy(a_song.patterns[self.channel_orderlist[self.orderlist_index]][self.row_index]) # If row contains a note, transpose if necessary (0 = no transform) if GT_NOTE_OFFSET <= row.note_data < GT_REST: # range $60 (C0) to $BC (G#7) note = row.note_data + self.curr_transposition assert note >= GT_NOTE_OFFSET, "Error: transpose dropped note below midi C0" # According to docs, allowed to transpose +3 halfsteps above the highest note (G#7) # that can be entered in the GT GUI, to create a B7 assert note <= GT_MAX_NOTE_VALUE, "Error: transpose raised note above midi B7" self.curr_note = pattern_note_to_midi_note(note) self.row_has_note = True # GT_REST ($BD/189, gt display "..."): A note continues through rest rows. Rest does not mean # what it would in sheet music. For our purposes, we're ignoring it # GT_KEY_OFF ($BE/190, gt display "---"): Unsets the gate bit mask. This starts the release phase # of the ADSR. # Going to ignore any effects gateoff timer and hardrestart values might have on perceived note end if row.note_data == GT_KEY_OFF: if self.curr_note is not None: self.row_has_key_off = True # GT_KEY_ON ($BF/191, gt display "+++"): Sets the gate bit mask (ANDed with data from the wavetable). # If no prior note has been started, then nothing will happen. If a note is playing, # nothing will happen (to the note, to the instrument, etc.). If a note was turned off, # this will restart it, but will not restart the instrument. if row.note_data == GT_KEY_ON: if self.curr_note is not None: self.row_has_key_on = True # Notes on funktempo (all this logic gleaned from reading through gplay.c) # # Funktempo allows switching between two tempos on alternating pattern rows, to achieve # a "swing" or more organic feel. # - for non-multispeed songs, it defaults to 9 and 6 # # The funktempo command is $E followed by an index to a single row in the speed table # - The left/right values in the speedtable row contain the two (alternating) tempo values # - Under the covers (in gplay.c), the array funktable[2] holds the two tempos # - e.g., command E04 points to speedtable at index 4. If the speedtable row contains # 01:09 06, then the alternating tempos are 9 and 6. For a 4x-multispeed, these # would need to be set instead to 01:24 18 # - the two values in funktable[] are global to all participating channels # - The command applies to all channels (3 or 6 for stereo) and all channels are set to # tempo 0 # # The tempo command is $F, and "tempos" $00 and $01 change all channels to the tempo that's # been previously set in funktable[0] or funktable[1] respectively, and every subsequent # row will alternate between the [0] and [1] entries of the funktable. In otherwords, # you can choose which half of the funktempo to start with. # - Values $80 and $81 are like $00 and $01, but apply funktempo to just the current channel # - Since the $E command sets all tempos to 0 (see above), it will always start with # funktable[0]'s tempo (set by the left-side entry in the speed table). But $F can choose # to start with the (previously-set) first or second value in the funktempo pair. if row.command == 0x0E: # funktempo command speed_table_index = row.command_data if speed_table_index > a_song.speed_table.row_cnt: raise ChiptuneSAKContentError("Error: speed table index %d too big for table of size %d" % (speed_table_index, a_song.speed_table.row_cnt)) # look up the two funk tempos in the speed table and set the channel-shared funktable speed_table_index -= 1 # convert to zero-indexing GtChannelState.funktable[0] = a_song.speed_table.left_col[speed_table_index] GtChannelState.funktable[1] = a_song.speed_table.right_col[speed_table_index] new_row_duration = GtChannelState.funktable[0] # Record global funktempo change self.global_tempo_update = 0 # 0 will later become the tempo in funktable entry 0 elif row.command == GT_TEMPO_CHNG_CMD: # From docs: # Values $03-$7F set tempo on all channels, values $83-$FF only on current channel (subtract # $80 to get actual tempo). Tempos $00-$01 recall the funktempo values set by EXY command. # Note: The higher voice number seems to win ties on simultaneous speed changes if row.command_data in [0x02, 0x82]: raise ChiptuneSAKValueError( "Unimplemented: Don't know how to support tempo change with value %d" % row.command_data) new_row_duration = row.command_data & 127 # don't care if it's global or local if new_row_duration < 2: new_row_duration = GtChannelState.funktable[new_row_duration] # Record global tempo change # From looking at the gt source code (at least for the goat tracker gui's gplay.c) # when a CMD_SETTEMPO happens (for one or for all three/six channels), the tempos immediately # change, but the ticks remaining on each channel's current row (in progress) is left alone -- # another detail that would have been nice to have had in the documentation. if 0x03 <= row.command_data <= 0x7F: self.global_tempo_update = row.command_data # Record tempo change for just the given channel if 0x83 <= row.command_data <= 0xFF: self.local_tempo_update = row.command_data - 0x80 # Record global funktempo change (funktable tempo entry 0 or 1) if 0x00 <= row.command_data <= 0x01: self.global_tempo_update = row.command_data # Record funktempo change for just the given channel (funktable tempo entry 0 or 1) if 0x80 <= row.command_data <= 0x81: self.global_tempo_update = row.command_data - 0x80 else: # given no tempo command on this row (0x0E or 0x0F), if we're in funktempo mode, time to alternate # our funktempo if self.curr_funktable_index is not None: self.curr_funktable_index ^= 1 self.local_tempo_update = self.curr_funktable_index new_row_duration = GtChannelState.funktable[self.curr_funktable_index] # init duration of this row # (if it hasn't started to count down, a row's init duration can get overwritten by # another channel's global temp setting, performed later in this code) if new_row_duration is not None: self.row_ticks_left = new_row_duration else: self.row_ticks_left = self.curr_tempo # FUTUREs: Possibly handle some of the (below) commands in the future? # from docs: # Command 1XY: Portamento up. XY is an index to a 16-bit speed value in the speedtable. # # Command 2XY: Portamento down. XY is an index to a 16-bit speed value in the speedtable. # # Command 3XY: Toneportamento. Raise or lower pitch until target note has been reached. XY is an index # to a 16-bit speed value in the speedtable, or $00 for "tie-note" effect (move pitch instantly to # target note) # # Command DXY: Set mastervolume to Y, if X is $0. If X is not $0, value XY is # copied to the timing mark location, which is playeraddress+$3F. return row # Advance to next row in pattern. If pattern end, then go to row 0 of next pattern in orderlist def inc_to_next_row(self, patterns): self.row_index += 1 # init val is -1 self.row_has_note = self.row_has_key_on = self.row_has_key_off = False self.local_tempo_update = self.global_tempo_update = None self.first_tick_of_row = True row = patterns[self.channel_orderlist[self.orderlist_index]][self.row_index] if row == PATTERN_END_ROW: self.pat_remaining_plays -= 1 assert self.pat_remaining_plays >= 0, "Error: negative number of remaining plays for pattern" self.row_index = 0 # all patterns are guaranteed to start with at least one meaningful (not end mark) row if self.pat_remaining_plays == 0: # all done with this pattern, moving on self.__inc_orderlist_to_next_pattern() def __inc_orderlist_to_next_pattern(self): self.pat_remaining_plays = 1 # patterns default to one playthrough unless otherwise specified while True: self.orderlist_index += 1 # bootstraps at -1 a_byte = self.channel_orderlist[self.orderlist_index] # parse transpose # Transpose is in half steps. Transposes changes are absolute, not additive. # If transpose combined with repeat, transpose must come before a repeat # Testing shows transpose ranges from '-F' (225) to '+E' (254) in orderlist # Bug in goattracker documentation: says range is $E0 (224) to $FE (254) # I'm assuming byte 224 is never used in orderlists if a_byte == 0xE0: raise ChiptuneSAKValueError("Unimplemented: Don't believe byte E0 should occur in the orderlist") if 0xE1 <= a_byte <= 0xFE: # F0 = +0 = no transposition self.curr_transposition = a_byte - 0xF0 # transpose range is -15 to +14 continue # parse repeat # Repeat values 1 to 16. In tracker, instead of R0..RF, it's R1..RF,R0 # i.e., 'R0'=223=16reps, 'RF'=222=15 reps, 'R1'=208=1rep # Note: Repeat n really means repeat n-1, so it's actually "number of times to play" # So R1 (repeat 1) is essentially a NOP if 0xD0 <= a_byte <= 0xDF: self.pat_remaining_plays = a_byte - 0xCF continue # parse RST (restart) if a_byte == GT_OL_RST: # RST ($FF) self.restarted = True start_index = self.channel_orderlist[ self.orderlist_index + 1] # byte following RST is orderlist restart index end_index = self.orderlist_index # byte containing RST self.orderlist_index = self.channel_orderlist[self.orderlist_index + 1] # perform orderlist "goto" jump # check if there's at least one pattern between the restart location and the RST if sum(1 for p in self.channel_orderlist[start_index:end_index] if p < GT_MAX_PATTERNS_PER_SONG) == 0: self.orderlist_index = None break # no pattern to ultimately end up on, so we're done # continue loop, just in case we land on a repeat or transpose that needs resolving self.orderlist_index -= 1 # "undo" +1 at start of loop continue # parse pattern if a_byte < GT_MAX_PATTERNS_PER_SONG: # if it's a pattern break # found one, done parsing raise ChiptuneSAKException("Error: found uninterpretable value %d in orderlist" % a_byte) # if __name__ == "__main__": # pass