Source code for ndspy.soundSequenceArchive

# 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 SSARs.
"""

import struct

from . import _common
from . import soundSequence


[docs]class SSAR: """ A SSAR*sequence archive file. This contains a blob of sequence events data, and a list of "sequences" that are essentially just pointers to starting locations in that data. """ # When saving SDAT, two otherwise identical SSARs will share data # only if their dataMergeOptimizationIDs are the same. # You can pretty safely ignore this. dataMergeOptimizationID = 0 _events = None sequences = None def __init__(self, file=None, unk02=0, names=None): self.unk02 = unk02 self.sequences = [] if file is not None: if not file.startswith(b'SSAR'): raise ValueError("Wrong magic (should be b'SSAR', instead" f' found {file[:4]})') # Read SSAR header magic, bom, version, filesize, headersize, numblocks = \ _common.NDS_STD_FILE_HEADER.unpack_from(file, 0) if version != 0x100: raise ValueError(f'Unsupported SSAR version: {version}') assert magic == b'SSAR', f'Incorrect SSAR magic ({magic})' # Read DATA block header dataMagic, dataSize, dataOffset, dataCount = \ struct.unpack_from('<4s3I', file, 0x10) assert dataMagic == b'DATA', f'Incorrect SSAR DATA magic ({dataMagic})' # Pad the length of the names list to the number of # sequences names = list(names) if names is not None else [] while len(names) < dataCount: names.append(None) self.eventsData = file[dataOffset:filesize] dataArrayPos = 0x20 for i in range(dataCount): (sequenceOffset, bankID, volume, channelPressure, polyphonicPressure, playerID) = \ struct.unpack_from('<iH4B', file, dataArrayPos) dataArrayPos += 0xC seq = SSARSequence(sequenceOffset, bankID, volume, channelPressure, polyphonicPressure, playerID, parsed=False) self.sequences.append((names[i], seq)) self.parsed = False else: self._events = [] self.parsed = True
[docs] @classmethod def fromEventsAndSequences(cls, events, sequences, unk02=0): """ Create a new SSAR object from a list of sequence events and a list of sequences. """ obj = cls(unk02=unk02) obj.events = events obj.sequences = sequences return obj
[docs] @classmethod def fromFile(cls, filePath, *args, **kwargs): """ Load an SSAR from a filesystem file. """ with open(filePath, 'rb') as f: return cls(f.read(), *args, **kwargs)
@property def events(self): if not self.parsed: raise ValueError('You must parse the SSAR with .parse()' ' before you can access .events!') return self._events @events.setter def events(self, value): self._events = value @property def eventsData(self): if self.parsed: raise ValueError('You cannot use .eventsData after you have' ' parsed the SSAR!') return self._eventsData @eventsData.setter def eventsData(self, value): self._eventsData = value
[docs] def parse(self): """ Attempt to process .eventsData to create .events. If successful, this switches the SSAR from the unparsed to the parsed state. """ if self.parsed: return self._initFromData(self._eventsData) self.parsed = True del self._eventsData
def _initFromData(self, eventsData): """ Finish initializing the SSAR using events data. """ startOffs = [] startOffs2Seq = {} for i, (seqName, seq) in enumerate(self.sequences): if seq.firstEventOffset not in [-1, None]: startOffs.append(seq.firstEventOffset) # Put lists in the dictionary, because there can be # multiple sequences referencing the same offset if seq.firstEventOffset not in startOffs2Seq: startOffs2Seq[seq.firstEventOffset] = [] startOffs2Seq[seq.firstEventOffset].append(seq) self.events, startEvents = soundSequence.readSequenceEvents( eventsData, startOffs) for event, originalOff in zip(startEvents, startOffs): for seq in startOffs2Seq[originalOff]: # These have to happen in this order seq.parsed = True seq.firstEvent = event
[docs] def save(self): """ Generate file data representing this SSAR, and then return that data, .unk02, and a list of sequence names as a triple. This matches the parameters of the default class constructor. """ tableData = bytearray() if self.parsed: startEvents = [] seq2StartEvent = {} for seqName, seq in self.sequences: if not seq.parsed: raise ValueError('Attempting to save a parsed SSAR, but' f' {seqName} is not parsed!') if seq.firstEvent is not None: startEvents.append(seq.firstEvent) seq2StartEvent[seq] = seq.firstEvent eventsData, startOffs = soundSequence.saveSequenceEvents(self.events, startEvents) for seqName, seq in self.sequences: if seq in seq2StartEvent: off = startOffs[startEvents.index(seq2StartEvent[seq])] else: off = -1 s = struct.pack('<iH4B2x', off, *(seq.save()[1:])) tableData.extend(s) else: eventsData = self.eventsData for seqName, seq in self.sequences: if seq.parsed: raise ValueError('Attempting to save an unparsed SSAR, but' f' {seqName} is parsed!') feo = seq.firstEventOffset if feo is None: feo = -1 s = struct.pack('<iH4B2x', feo, *(seq.save()[1:])) tableData.extend(s) dataOffset = 0x20 + len(self.sequences) * 0xC fileLen = dataOffset + len(eventsData) data = bytearray() data.extend(_common.NDS_STD_FILE_HEADER.pack(b'SSAR', 0xFEFF, 0x100, fileLen, 16, 1)) data.extend(struct.pack('<4s3I', b'DATA', fileLen - 0x10, dataOffset, len(self.sequences))) data.extend(tableData) data.extend(eventsData) return (data, self.unk02, [seqName for (seqName, seq) in self.sequences])
[docs] def saveToFile(self, filePath): """ Generate file data representing this SSAR, and save it to a filesystem file. """ d = self.save()[0] with open(filePath, 'wb') as f: f.write(d)
def __str__(self): if not self.parsed: names = [str(n) for (n, s) in self.sequences] return f'<ssar unparsed [{", ".join(names)}]>' linesList = ['<ssar'] linesList.append(soundSequence.printSequenceEventList( self.events, {seqName: seq.firstEvent for (seqName, seq) in self.sequences}, ' ' * 4)) linesList.append('>') return '\n'.join(linesList) def __repr__(self): if self.parsed: return f'{type(self).__name__}.fromEventsAndSequences({self.events!r}, {self.sequences!r})' else: data = _common.shortBytesRepr(self.eventsData) names = [str(n) for (n, s) in self.sequences] return f'{type(self).__name__}({data}, names={names!r})'
[docs]class SSARSequence: """ A sequence within a *SSAR* sequence archive file. These generally contain sound effects. """ def __init__(self, firstEvent_firstEventOffset, bankID=0, volume=127, channelPressure=64, polyphonicPressure=50, playerID=0, *, parsed=True): if parsed: self._firstEvent = firstEvent_firstEventOffset self.firstEventOffset = 0 else: self._firstEvent = None if firstEvent_firstEventOffset == -1: firstEvent_firstEventOffset = None self.firstEventOffset = firstEvent_firstEventOffset self.bankID = bankID self.volume = volume self.channelPressure = channelPressure self.polyphonicPressure = polyphonicPressure self.playerID = playerID self.parsed = parsed @property def firstEvent(self): if not self.parsed: raise ValueError('You must parse the SSAR with .parse() before you' ' can access .firstEvent in sequences!') return self._firstEvent @firstEvent.setter def firstEvent(self, value): self._firstEvent = value @property def firstEventOffset(self): if self.parsed: raise ValueError('You cannot access .firstEventOffset after you' ' have parsed the SSAR!') return self._firstEventOffset @firstEventOffset.setter def firstEventOffset(self, value): self._firstEventOffset = value
[docs] def save(self): """ Return this SSAR sequence's first event or first event offset, .bankID, .volume, .channelPressure, .polyphonicPressure, and .playerID as a 6-tuple. This matches the parameters of the default class constructor. """ return (self.firstEvent if self.parsed else self.firstEventOffset, self.bankID, self.volume, self.channelPressure, self.polyphonicPressure, self.playerID)
def __str__(self): up = ' unparsed' if not self.parsed else '' fields = [] fields.append(f'bank={self.bankID}') fields.append(f'volume={self.volume}') fields.append(f'playerID={self.playerID}') return f'<ssar-sequence{up} {" ".join(fields)}>' def __repr__(self): fields = [] fields.append(repr(self.firstEvent if self.parsed else self.firstEventOffset)) fields.append(repr(self.bankID)) fields.append(repr(self.volume)) fields.append(repr(self.channelPressure)) fields.append(repr(self.polyphonicPressure)) fields.append(repr(self.playerID)) if not self.parsed: fields.append('parsed=False') return f'{type(self).__name__}({", ".join(fields)})'