Source code for ndspy.soundBank

# Copyright 2019 RoadrunnerWMC
#
# This file is part of ndspy.
#
# ndspy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# ndspy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with ndspy.  If not, see <https://www.gnu.org/licenses/>.
"""
Support for SBNK instrument banks.
"""


import enum
import struct

from . import _common


# Constants for various instrument types
NO_INSTRUMENT_TYPE = 0
SINGLE_NOTE_PCM_INSTRUMENT_TYPE = 1
SINGLE_NOTE_PSG_SQUARE_WAVE_INSTRUMENT_TYPE = 2
SINGLE_NOTE_PSG_WHITE_NOISE_INSTRUMENT_TYPE = 3
RANGE_INSTRUMENT_TYPE = 16
REGIONAL_INSTRUMENT_TYPE = 17


[docs]class NoteType(enum.IntEnum): """ An enumeration that distinguishes between the three primary types of note definitions. """ PCM = 1 PSG_SQUARE_WAVE = 2 PSG_WHITE_NOISE = 3
[docs]class NoteDefinition: """ A note definition within a SBNK instrument. """ def __init__(self, waveID_dutyCycle=0, waveArchiveIDID=0, pitch=60, attack=127, decay=127, sustain=127, release=127, pan=64, type=1): self.waveID = waveID_dutyCycle self.waveArchiveIDID = waveArchiveIDID self.pitch = pitch self.attack = attack self.decay = decay self.sustain = sustain self.release = release self.pan = pan if type in [1, 2, 3]: type = NoteType(type) self.type = type @property def dutyCycle(self): return self.waveID @dutyCycle.setter def dutyCycle(self, value): self.waveID = value
[docs] @classmethod def fromData(cls, data, type=1): """ Create a note definition from raw file data that does not include the type value at the beginning. """ return cls(*struct.unpack_from('<HH6B', data), type)
[docs] @classmethod def fromDataWithType(cls, data): """ Create a note definition from raw file data that includes the type value at the beginning. """ values = struct.unpack_from('<3H6B', data) return cls(*values[1:], values[0])
[docs] def save(self): """ Generate data representing this note definition, without including the type value. """ return struct.pack('<HH6B', self.waveID, self.waveArchiveIDID, self.pitch, self.attack, self.decay, self.sustain, self.release, self.pan)
[docs] def saveWithType(self): """ Generate data representing this note definition, including the type value at the beginning. """ return struct.pack('<H', self.type) + self.save()
def __str__(self): name = _common.noteName(self.pitch) if self.type == NoteType.PSG_SQUARE_WAVE: pct = ['12.5%', '25%', '37.5%', '50%', '62.5%', '75%', '87.5%', '0%'][self.dutyCycle & 7] extraInfo = f'PSG square wave: {pct} duty cycle' elif self.type == NoteType.PSG_WHITE_NOISE: extraInfo = 'PSG white noise' else: extraInfo = f'PCM: SWAR {self.waveArchiveIDID}, SWAV {self.waveID}' return f'<note def {name} ({extraInfo})>' def __repr__(self): params = ', '.join(repr(s) for s in [self.waveID, self.waveArchiveIDID, self.pitch, self.attack, self.decay, self.sustain, self.release, self.pan, self.type]) return f'{type(self).__name__}({params})'
[docs]class Instrument: """ An instrument within a SBNK file. This is an abstract base class, and should be subclassed in order to be used. """ bankOrderKey = 0 dataMergeOptimizationID = 0 def __init__(self, type): """ Initialize the instrument. """ self.type = type
[docs] @classmethod def fromData(cls, type, data, startOffset): """ Create an instrument from raw file data. This method must be implemented in subclasses; this abstract-base-class implementation simply raises NotImplementedError. """ raise NotImplementedError('Override fromData() in Instrument ' 'subclasses!')
[docs] def save(self): """ Return the instrument's type value as a 1-tuple. Subclasses may return longer tuples with more data; currently, all subclasses add a :py:class:`bytes` instance. """ return (self.type,)
[docs]class SingleNoteInstrument(Instrument): """ An instrument that contains one note definition and nothing else. This class encompasses instrument type values 1 through 15. """ def __init__(self, noteDefinition): self.noteDefinition = noteDefinition super().__init__(int(noteDefinition.type)) @property def type(self): return int(self.noteDefinition.type) @type.setter def type(self, value): if value in [1, 2, 3]: value = NoteType(value) self.noteDefinition.type = value
[docs] @classmethod def fromData(cls, type, data, startOffset): """ Create a single-note instrument from raw file data. """ noteData = data[startOffset : startOffset + 10] noteDefinition = NoteDefinition.fromData(noteData, type) return cls(noteDefinition), 10
[docs] def save(self): """ Generate file data representing this instrument, and then return the instrument's type value and that data as a pair. """ return super().save() + (self.noteDefinition.save(),)
def __str__(self): return f'<single-note instrument {self.noteDefinition}>' def __repr__(self): return (f'{type(self).__name__}({self.noteDefinition!r})')
[docs]class RangeInstrument(Instrument): """ An instrument that contains one note definition for each pitch in a given range. """ def __init__(self, firstPitch, noteDefinitions): super().__init__(RANGE_INSTRUMENT_TYPE) self.firstPitch = firstPitch self.noteDefinitions = noteDefinitions
[docs] @classmethod def fromData(cls, _, data, startOffset): """ Create a range instrument from raw file data. """ firstPitch, lastPitch = \ struct.unpack_from('<BB', data, startOffset) noteDefinitions = [] off = startOffset + 2 for i in range(lastPitch - firstPitch + 1): noteData = data[off : off+0xC] noteDefinitions.append( NoteDefinition.fromDataWithType(noteData)) off += 0xC return (cls(firstPitch, noteDefinitions), off - startOffset)
[docs] def save(self): """ Generate file data representing this instrument, and then return the instrument's type value and that data as a pair. """ data = bytearray(2 + 0xC * len(self.noteDefinitions)) struct.pack_into('<BB', data, 0, self.firstPitch, self.firstPitch + len(self.noteDefinitions) - 1) off = 2 for n in self.noteDefinitions: data[off : off+0xC] = n.saveWithType() off += 0xC return super().save() + (data,)
def __str__(self): joinList = [] for i, d in enumerate(self.noteDefinitions): joinList.append( f'{_common.noteName(i + self.firstPitch)}: {d}') defs = ', '.join(joinList) firstNote = _common.noteName(self.firstPitch) return f'<range instrument starting at {firstNote} {{{defs}}}>' def __repr__(self): defs = ', '.join(repr(d) for d in self.noteDefinitions) return (f'{type(self).__name__}(' f'{self.firstPitch!r}, ' f'[{defs}])')
[docs]class RegionalInstrument(Instrument): """ An instrument that partitions the range [0, 127] into sections, and contains one note definition for each. """
[docs] class Region: """ A region within a regional instrument. """ def __init__(self, lastPitch, noteDefinition): self.lastPitch = lastPitch self.noteDefinition = noteDefinition def __str__(self): return (f'<region through {_common.noteName(self.lastPitch)} ' f'{self.noteDefinition}>') def __repr__(self): return (f'{type(self).__name__}(' f'{self.lastPitch!r}, ' f'{self.noteDefinition!r})')
def __init__(self, regions): super().__init__(REGIONAL_INSTRUMENT_TYPE) self.regions = regions
[docs] @classmethod def fromData(cls, _, data, startOffset): """ Create a regional instrument from raw file data. """ regionEnds = struct.unpack_from('8B', data, startOffset) regions = [] off = startOffset + 8 for e in regionEnds: if e == 0 and off != startOffset + 8: break noteData = data[off : off+0xC] note = NoteDefinition.fromDataWithType(noteData) off += 0xC regions.append(cls.Region(e, note)) return cls(regions), off - startOffset
[docs] def save(self): """ Generate file data representing this instrument, and then return the instrument's type value and that data as a pair. """ if len(self.regions) > 8: raise ValueError("Can't save RegionalInstrument -- too many " f'regions ({len(self.regions)})!') data = bytearray(8 + 0xC * len(self.regions)) for i, region in enumerate(self.regions): data[i] = region.lastPitch data[8 + 0xC * i : 0x14 + 0xC * i] = \ region.noteDefinition.saveWithType() return super().save() + (data,)
def __str__(self): fill = [] last = 0 for region in self.regions: def betterNoteName(num): if num == 0: return 'min' elif num == 127: return 'max' else: return _common.noteName(num) fill.append(f'{betterNoteName(last)}' f'-{betterNoteName(region.lastPitch)}: ' f'{region.noteDefinition}') last = region.lastPitch + 1 return f'<regional instrument {{{", ".join(fill)}}}>' def __repr__(self): return (f'{type(self).__name__}(' f'[{", ".join(repr(r) for r in self.regions)}])')
[docs]def instrumentClass(type): """ A convenience function that returns the Instrument subclass that should be used to load an instrument with the given type value. """ if type == NO_INSTRUMENT_TYPE: # 0 return None elif type < RANGE_INSTRUMENT_TYPE: # < 16 return SingleNoteInstrument elif type == RANGE_INSTRUMENT_TYPE: # 16 return RangeInstrument elif type == REGIONAL_INSTRUMENT_TYPE: # 17 return RegionalInstrument else: raise ValueError(f'Instrument type {type} is invalid.')
[docs]def guessInstrumentType(data, startOffset, possibleTypes, bytesAvailable): """ Try to guess the type of instrument stored in some binary data based on both the data and a set of possible types (ones that haven't been ruled out by the instrument's position in the surrounding data). This function is entirely based on heuristics, so it may return different answers for similar data, and it cannot always be accurate. Types 1, 2 and 3 are considered equivalent by this function, since they are very similar and all use the same Python class (SingleNoteInstrument). None will be returned if it's very unlikely that there is an instrument at that position. """ possibleTypes = set(possibleTypes) # Types 2 and 3 (the PSG single-note instruments) should be # considered equivalent to type 1 (PCM single-note instrument) if SINGLE_NOTE_PSG_SQUARE_WAVE_INSTRUMENT_TYPE in possibleTypes: possibleTypes.remove(SINGLE_NOTE_PSG_SQUARE_WAVE_INSTRUMENT_TYPE) possibleTypes.add(SINGLE_NOTE_PCM_INSTRUMENT_TYPE) if SINGLE_NOTE_PSG_WHITE_NOISE_INSTRUMENT_TYPE in possibleTypes: possibleTypes.remove(SINGLE_NOTE_PSG_WHITE_NOISE_INSTRUMENT_TYPE) possibleTypes.add(SINGLE_NOTE_PCM_INSTRUMENT_TYPE) # By doing # if returnEarly(): return early() # you can quickly check if len(possibleTypes) < 2, and return the # appropriate type or None. def returnEarly(): return len(possibleTypes) < 2 def early(): if possibleTypes: return list(possibleTypes)[0] else: return None # Return immediately if possible if returnEarly(): return early() # Start by ruling out the impossible... def ruleOut(type): if type in possibleTypes: possibleTypes.remove(type) if bytesAvailable < 10: ruleOut(NO_INSTRUMENT_TYPE) if bytesAvailable < 2 + 0xC: ruleOut(RANGE_INSTRUMENT_TYPE) if bytesAvailable < 8 + 0xC: ruleOut(REGIONAL_INSTRUMENT_TYPE) if returnEarly(): return early() # Then rule out the improbable... if SINGLE_NOTE_PCM_INSTRUMENT_TYPE in possibleTypes: # Among the games I've looked at, SWAV ID and SWAR ID are never # each more than 0xA00, so we can use that as a reasonable limit if data[startOffset + 1] >= 10: ruleOut(SINGLE_NOTE_PCM_INSTRUMENT_TYPE) if data[startOffset + 3] >= 10: ruleOut(SINGLE_NOTE_PCM_INSTRUMENT_TYPE) # And pitch will probably never be 0, right? if data[startOffset + 4] == 0: ruleOut(SINGLE_NOTE_PCM_INSTRUMENT_TYPE) # For that matter, if pitch is 0x3C (middle C), we can probably # just assume straight away that this is a single-note inst if data[startOffset + 4] == 0x3C: return SINGLE_NOTE_PCM_INSTRUMENT_TYPE if returnEarly(): return early() if RANGE_INSTRUMENT_TYPE in possibleTypes: firstPitch, lastPitch = data[startOffset : startOffset+2] if firstPitch > lastPitch: ruleOut(RANGE_INSTRUMENT_TYPE) else: expectedLen = 2 + 0xC * (lastPitch - firstPitch + 1) if expectedLen > bytesAvailable: ruleOut(RANGE_INSTRUMENT_TYPE) if returnEarly(): return early() if REGIONAL_INSTRUMENT_TYPE in possibleTypes: regionEnds = data[startOffset : startOffset+8] # Check that all ends are strictly increasing, followed by # all zeroes # (not every regional instrument ends with 7F, surprisingly) previous = -1 for end in regionEnds: if previous != 0 and end == 0: # first zero byte previous = 0 elif previous == 0 and end != 0: # nonzero byte after a zero byte? bad bad bad ruleOut(REGIONAL_INSTRUMENT_TYPE) break elif previous != 0: if end <= previous: ruleOut(REGIONAL_INSTRUMENT_TYPE) break previous = end else: # Finally, check that we have sufficient length regionCount = 0 for e in regionEnds: if not e: break regionCount += 1 expectedLen = 8 + 0xC * regionCount if expectedLen > bytesAvailable: ruleOut(REGIONAL_INSTRUMENT_TYPE) if returnEarly(): return early() # At this point, just pick one of the remaining options at random. return early()
[docs]class SBNK: """ A SBNK instrument bank file. This defines a set of instruments that sequences and sequence archives can use. """ # When saving SDAT, two otherwise identical SBNKs will share data # only if their dataMergeOptimizationIDs are the same. # You can pretty safely ignore this. dataMergeOptimizationID = 0 def __init__(self, file=None, unk02=0, waveArchiveIDs=None): self.unk02 = unk02 if waveArchiveIDs is None: self.waveArchiveIDs = [] else: self.waveArchiveIDs = list(waveArchiveIDs) # Remove trailing `None`s if they exist while self.waveArchiveIDs and self.waveArchiveIDs[-1] is None: self.waveArchiveIDs = self.waveArchiveIDs[:-1] self.instruments = [] self.inaccessibleInstruments = {} if file is not None: if not file.startswith(b'SBNK'): raise ValueError("Wrong magic (should be b'SBNK', instead " f'found {file[:4]})') self._initFromData(file)
[docs] @classmethod def fromInstruments(cls, instruments, unk02=0, waveArchiveIDs=None): """ Create a SBNK from a list of instruments. """ obj = cls(unk02=unk02, waveArchiveIDs=waveArchiveIDs) obj.instruments = instruments return obj
[docs] @classmethod def fromFile(cls, filePath, *args, **kwargs): """ Load an SBNK from a filesystem file. """ with open(filePath, 'rb') as f: return cls(f.read(), *args, **kwargs)
def _initFromData(self, data): """ Initialize the SBNK from file data. """ magic, bom, version, filesize, headersize, numblocks = \ _common.NDS_STD_FILE_HEADER.unpack_from(data, 0) assert magic == b'SBNK', f'Incorrect SBNK magic ({magic})' if version != 0x100: raise ValueError(f'Unsupported SBNK version: {version}') dataMagic, dataSize, instrumentCount = \ struct.unpack_from('<4sI32xI', data, 0x10) assert dataMagic == b'DATA', f'Incorrect SBNK DATA magic ({dataMagic})' unconsumedBytes = set(range(0x3C + instrumentCount * 4, filesize)) idsToOffsets = {} def makeInstrumentAt(type, offset): instrumentCls = instrumentClass(type) if instrumentCls is None: instrument = None consumedBytesHere = 0 else: instrument, consumedBytesHere = instrumentCls.fromData(type, data, offset) instrument.bankOrderKey = offset instrument.dataMergeOptimizationID = offset for j in range(consumedBytesHere): if offset + j in unconsumedBytes: unconsumedBytes.remove(offset + j) return instrument dataArrayPos = 0x3C for id in range(instrumentCount): (instrumentType, instrumentOffset) = \ struct.unpack_from('<BHx', data, dataArrayPos) dataArrayPos += 4 instrument = makeInstrumentAt(instrumentType, instrumentOffset) self.instruments.append(instrument) if instrument is not None: idsToOffsets[id] = instrumentOffset # Why do we have to do this D: while unconsumedBytes: # Account for possible 2 bytes of padding at the end if (filesize - 1 in unconsumedBytes and filesize - 2 in unconsumedBytes and filesize - 3 not in unconsumedBytes): unconsumedBytes.remove(filesize - 1) unconsumedBytes.remove(filesize - 2) if not unconsumedBytes: break thisOffset = min(unconsumedBytes) # Find the previous and following instrument IDs prevID = -1 prevOffset = -1 nextID = 9E9 nextOffset = 9E9 for id, offset in idsToOffsets.items(): if offset < thisOffset and offset > prevOffset: prevID = id prevOffset = offset if offset > thisOffset and offset < nextOffset: nextID = id nextOffset = offset if prevID == -1: prevID = None if nextID == 9E9: nextID = None # Find possible types for this instrument based on that possibleTypes = {SINGLE_NOTE_PCM_INSTRUMENT_TYPE, RANGE_INSTRUMENT_TYPE, REGIONAL_INSTRUMENT_TYPE} prevInst = None if prevID is None else self.instruments[prevID] nextInst = None if nextID is None else self.instruments[nextID] if prevInst is not None: if prevInst.type >= RANGE_INSTRUMENT_TYPE: possibleTypes.remove(1) if prevInst.type == REGIONAL_INSTRUMENT_TYPE: possibleTypes.remove(RANGE_INSTRUMENT_TYPE) if nextInst is not None: if (nextInst.type <= RANGE_INSTRUMENT_TYPE and REGIONAL_INSTRUMENT_TYPE in possibleTypes): possibleTypes.remove(REGIONAL_INSTRUMENT_TYPE) if (nextInst.type < RANGE_INSTRUMENT_TYPE and RANGE_INSTRUMENT_TYPE in possibleTypes): possibleTypes.remove(RANGE_INSTRUMENT_TYPE) # Count the number of contiguous bytes we've got tempOffset = thisOffset + 1 while tempOffset in unconsumedBytes: tempOffset += 1 bytesAvailable = tempOffset - thisOffset # Use advanced heuristics™ to determine this instrument's # type instrumentType = guessInstrumentType(data, thisOffset, possibleTypes, bytesAvailable) if instrumentType is None: instrument = None else: try: instrument = makeInstrumentAt(instrumentType, thisOffset) except Exception: instrument = None if instrument is None: # Couldn't make an instrument there, so, assume that # it's garbage data that we have to skip (to avoid an # infinite loop) unconsumedBytes.remove(thisOffset) if thisOffset + 1 in unconsumedBytes: unconsumedBytes.remove(thisOffset + 1) else: if prevID not in self.inaccessibleInstruments: self.inaccessibleInstruments[prevID] = [] self.inaccessibleInstruments[prevID].append(instrument)
[docs] def save(self): """ Generate file data representing this SBNK, and then return that data, .unk02, and .waveArchiveIDs as a triple. """ data = bytearray(0x3C + 4 * len(self.instruments)) instrumentsData = bytearray() # Instrument data (that the offset points to) is stored in order of: # - Single-note instruments # - Range instruments # - Regional instruments # Yet the instruments offsets table is in the order that forms # the instruments IDs exposed to other files. So we save in two # steps. # Also, the order within each of those three categories can be # arbitrary. So instruments have a bankOrderKey that can be used # to sort within each category. # First, put together the instruments data itself indexToOffset = [None for _ in self.instruments] dataReuseCache = {} def addInstrument(instrument): instrumentType, instrumentData = instrument.save() instrumentData = bytes(instrumentData) mergeID = instrument.dataMergeOptimizationID if (instrumentData, mergeID) not in dataReuseCache: dataReuseCache[(instrumentData, mergeID)] = \ len(instrumentsData) instrumentsData.extend(instrumentData) return dataReuseCache[(instrumentData, mergeID)] test1 = lambda id: id < RANGE_INSTRUMENT_TYPE test2 = lambda id: id == RANGE_INSTRUMENT_TYPE test3 = lambda id: id == REGIONAL_INSTRUMENT_TYPE if None in self.inaccessibleInstruments: for inacc in self.inaccessibleInstruments[None]: addInstrument(inacc) for test in (test1, test2, test3): inThisGroup = [] for i, instrument in enumerate(self.instruments): if instrument is None: continue if test(instrument.type): inThisGroup.append((i, instrument)) inThisGroup.sort(key=lambda elem: elem[1].bankOrderKey) for i, instrument in inThisGroup: indexToOffset[i] = addInstrument(instrument) if i in self.inaccessibleInstruments: for inacc in self.inaccessibleInstruments[i]: addInstrument(inacc) # Add any inaccessible instruments with IDs that are too high idSet = set(self.inaccessibleInstruments) if None in idSet: idSet.remove(None) for inaccID in sorted(idSet): if inaccID < len(self.instruments): continue for inacc in self.inaccessibleInstruments[inaccID]: addInstrument(inacc) # And now construct the table using that. for i, instrument in enumerate(self.instruments): instrumentType = instrument.type if instrument is not None else 0 offs = indexToOffset[i] if offs is None: offs = 0 else: offs += len(data) struct.pack_into('<BH', data, 0x3C + 4 * i, instrumentType, offs) data.extend(instrumentsData) while len(data) % 4: data.append(0) _common.NDS_STD_FILE_HEADER.pack_into(data, 0, b'SBNK', 0xFEFF, 0x100, len(data), 0x10, 1) struct.pack_into('<4sI32xI', data, 0x10, b'DATA', len(data) - 0x10, len(self.instruments)) return (data, self.unk02, self.waveArchiveIDs)
[docs] def saveToFile(self, filePath): """ Generate file data representing this SBNK, and save it to a filesystem file. """ d = self.save()[0] with open(filePath, 'wb') as f: f.write(d)
def __str__(self): linesList = [f'<sbnk waveArchiveIDs={self.waveArchiveIDs}'] linesList.extend(_common.enumeratedListOfStrs(self.instruments)) linesList.append('>') return '\n'.join(linesList) def __repr__(self): return (f'{type(self).__name__}.fromInstruments(' f'{self.instruments!r}, ' f'waveArchiveIDs={self.waveArchiveIDs!r})')