PyCriCodecsEx 0.0.5__cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CriCodecsEx.cpython-312-x86_64-linux-gnu.so +0 -0
- PyCriCodecsEx/__init__.py +1 -0
- PyCriCodecsEx/acb.py +306 -0
- PyCriCodecsEx/adx.py +158 -0
- PyCriCodecsEx/awb.py +165 -0
- PyCriCodecsEx/chunk.py +92 -0
- PyCriCodecsEx/cpk.py +743 -0
- PyCriCodecsEx/hca.py +454 -0
- PyCriCodecsEx/usm.py +1001 -0
- PyCriCodecsEx/utf.py +692 -0
- pycricodecsex-0.0.5.dist-info/METADATA +35 -0
- pycricodecsex-0.0.5.dist-info/RECORD +15 -0
- pycricodecsex-0.0.5.dist-info/WHEEL +6 -0
- pycricodecsex-0.0.5.dist-info/licenses/LICENSE +21 -0
- pycricodecsex-0.0.5.dist-info/top_level.txt +2 -0
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.5"
|
PyCriCodecsEx/acb.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Credit:
|
|
2
|
+
# - github.com/vgmstream/vgmstream which is why this is possible at all
|
|
3
|
+
# - Original work by https://github.com/Youjose/PyCriCodecs
|
|
4
|
+
# See Research/ACBSchema.py for more details.
|
|
5
|
+
|
|
6
|
+
from typing import Generator, List, Tuple, BinaryIO
|
|
7
|
+
from PyCriCodecsEx.chunk import *
|
|
8
|
+
from PyCriCodecsEx.utf import UTF, UTFBuilder, UTFViewer
|
|
9
|
+
from PyCriCodecsEx.hca import HCACodec
|
|
10
|
+
from PyCriCodecsEx.adx import ADXCodec
|
|
11
|
+
from PyCriCodecsEx.awb import AWB, AWBBuilder
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
|
|
15
|
+
class CueNameTable(UTFViewer):
|
|
16
|
+
CueIndex: int
|
|
17
|
+
'''Index into CueTable'''
|
|
18
|
+
CueName: str
|
|
19
|
+
'''Name of the cue'''
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CueTable(UTFViewer):
|
|
23
|
+
CueId: int
|
|
24
|
+
'''Corresponds to the cue index found in CueNameTable'''
|
|
25
|
+
Length: int
|
|
26
|
+
'''Duration of the cue in milliseconds'''
|
|
27
|
+
ReferenceIndex: int
|
|
28
|
+
ReferenceType: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SequenceTable(UTFViewer):
|
|
32
|
+
NumTracks : int
|
|
33
|
+
TrackIndex: bytes
|
|
34
|
+
Type: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SynthTable(UTFViewer):
|
|
38
|
+
ReferenceItems: bytes
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TrackEventTable(UTFViewer):
|
|
42
|
+
Command: bytes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TrackTable(UTFViewer):
|
|
46
|
+
EventIndex: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class WaveformTable(UTFViewer):
|
|
50
|
+
EncodeType: int
|
|
51
|
+
MemoryAwbId: int
|
|
52
|
+
NumChannels: int
|
|
53
|
+
NumSamples: int
|
|
54
|
+
SamplingRate: int
|
|
55
|
+
Streaming: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ACBTable(UTFViewer):
|
|
59
|
+
'''ACB Table View'''
|
|
60
|
+
|
|
61
|
+
AcbGuid: bytes
|
|
62
|
+
'''GUID of the ACB. This SHOULD be different for each ACB file.'''
|
|
63
|
+
Name: str
|
|
64
|
+
'''Name of the ACB. This is usually the name of the sound bank.'''
|
|
65
|
+
Version: int
|
|
66
|
+
VersionString: str
|
|
67
|
+
|
|
68
|
+
AwbFile: bytes
|
|
69
|
+
CueNameTable: List[CueNameTable]
|
|
70
|
+
'''A list of cue names with their corresponding indices into CueTable'''
|
|
71
|
+
CueTable: List[CueTable]
|
|
72
|
+
'''A list of cues with their corresponding references'''
|
|
73
|
+
|
|
74
|
+
SequenceTable: List[SequenceTable]
|
|
75
|
+
SynthTable: List[SynthTable]
|
|
76
|
+
TrackEventTable: List[TrackEventTable]
|
|
77
|
+
TrackTable: List[TrackTable]
|
|
78
|
+
WaveformTable: List[WaveformTable]
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _decode_tlv(data : bytes):
|
|
82
|
+
pos = 0
|
|
83
|
+
while pos < len(data):
|
|
84
|
+
tag = data[pos : pos + 2]
|
|
85
|
+
length = data[pos + 3]
|
|
86
|
+
value = data[pos + 4 : pos + 4 + length]
|
|
87
|
+
pos += 3 + length
|
|
88
|
+
yield (tag, value)
|
|
89
|
+
|
|
90
|
+
def _waveform_of_track(self, index: int):
|
|
91
|
+
tlv = self._decode_tlv(self.TrackEventTable[index])
|
|
92
|
+
def noteOn(data: bytes):
|
|
93
|
+
# Handle note on event
|
|
94
|
+
tlv_type, tlv_index = AcbTrackCommandNoteOnStruct.unpack(data[:AcbTrackCommandNoteOnStruct.size])
|
|
95
|
+
match tlv_type:
|
|
96
|
+
case 0x02: # Synth
|
|
97
|
+
yield from self._waveform_of_synth(tlv_index)
|
|
98
|
+
case 0x03: # Sequence
|
|
99
|
+
yield from self._waveform_of_sequence(tlv_index)
|
|
100
|
+
# Ignore others silently
|
|
101
|
+
for code, data in tlv:
|
|
102
|
+
match code:
|
|
103
|
+
case 2000:
|
|
104
|
+
yield from noteOn(data)
|
|
105
|
+
case 2003:
|
|
106
|
+
yield from noteOn(data)
|
|
107
|
+
|
|
108
|
+
def _waveform_of_sequence(self, index : int):
|
|
109
|
+
seq = self.SequenceTable[index]
|
|
110
|
+
for i in range(seq.NumTracks):
|
|
111
|
+
track_index = int.from_bytes(seq.TrackIndex[i*2:i*2+2], 'big')
|
|
112
|
+
yield self.WaveformTable[track_index]
|
|
113
|
+
|
|
114
|
+
def _waveform_of_synth(self, index: int):
|
|
115
|
+
item_type, item_index = AcbSynthReferenceStruct.unpack(self.SynthTable[index].ReferenceItems)
|
|
116
|
+
match item_type:
|
|
117
|
+
case 0x00: # No audio
|
|
118
|
+
return
|
|
119
|
+
case 0x01: # Waveform
|
|
120
|
+
yield self.WaveformTable[item_index]
|
|
121
|
+
case 0x02: # Yet another synth...
|
|
122
|
+
yield from self._waveform_of_synth(item_index)
|
|
123
|
+
case 0x03: # Sequence
|
|
124
|
+
yield from self._waveform_of_sequence(item_index)
|
|
125
|
+
case _:
|
|
126
|
+
raise NotImplementedError(f"Unknown synth reference type: {item_type} at index {index}")
|
|
127
|
+
|
|
128
|
+
def waveform_of(self, index : int) -> List["WaveformTable"]:
|
|
129
|
+
"""Retrieves the waveform(s) associated with a cue.
|
|
130
|
+
|
|
131
|
+
Cues may reference multiple waveforms, which could also be reused."""
|
|
132
|
+
cue = next(filter(lambda c: c.CueId == index, self.CueTable), None)
|
|
133
|
+
assert cue, "cue of index %d not found" % index
|
|
134
|
+
match cue.ReferenceType:
|
|
135
|
+
case 0x01:
|
|
136
|
+
return [self.WaveformTable[index]]
|
|
137
|
+
case 0x02:
|
|
138
|
+
return list(self._waveform_of_synth(index))
|
|
139
|
+
case 0x03:
|
|
140
|
+
return list(self._waveform_of_sequence(index))
|
|
141
|
+
case 0x08:
|
|
142
|
+
raise NotImplementedError("BlockSequence type not implemented yet")
|
|
143
|
+
case _:
|
|
144
|
+
raise NotImplementedError(f"Unknown cue reference type: {cue.ReferenceType}")
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class PackedCueItem:
|
|
148
|
+
'''Helper class for read-only cue information'''
|
|
149
|
+
|
|
150
|
+
CueId: int
|
|
151
|
+
'''Cue ID'''
|
|
152
|
+
CueName: str
|
|
153
|
+
'''Cue name'''
|
|
154
|
+
Length: float
|
|
155
|
+
'''Duration in seconds'''
|
|
156
|
+
Waveforms: list[int]
|
|
157
|
+
'''List of waveform IDs, corresponds to ACB.get_waveforms()'''
|
|
158
|
+
|
|
159
|
+
class ACB(UTF):
|
|
160
|
+
"""Use this class to read, and modify ACB files in memory."""
|
|
161
|
+
def __init__(self, stream : str | BinaryIO) -> None:
|
|
162
|
+
"""Loads an ACB file from the given stream.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
stream (str | BinaryIO): The path to the ACB file or a BinaryIO stream containing the ACB data.
|
|
166
|
+
"""
|
|
167
|
+
super().__init__(stream, recursive=True)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def payload(self) -> dict:
|
|
171
|
+
"""Retrives the only UTF table dict within the ACB file."""
|
|
172
|
+
return self.dictarray[0]
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def view(self) -> ACBTable:
|
|
176
|
+
"""Returns a view of the ACB file, with all known tables mapped to their respective classes.
|
|
177
|
+
|
|
178
|
+
* Use this to interact with the ACB payload instead of `payload` for helper functions, etc"""
|
|
179
|
+
return ACBTable(self.payload)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def name(self) -> str:
|
|
183
|
+
"""Returns the name of the ACB file."""
|
|
184
|
+
return self.view.Name
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def awb(self) -> AWB:
|
|
188
|
+
"""Returns the AWB object associated with the ACB."""
|
|
189
|
+
return AWB(self.view.AwbFile)
|
|
190
|
+
|
|
191
|
+
def get_waveforms(self, **kwargs) -> List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]:
|
|
192
|
+
"""Returns a list of decoded waveforms.
|
|
193
|
+
|
|
194
|
+
Item may be a codec (if known), or a tuple of (Codec ID, Channel Count, Sample Count, Sample Rate, Raw data).
|
|
195
|
+
|
|
196
|
+
Additional keyword arguments are passed to the codec constructors. e.g. for encrypted HCA payloads,
|
|
197
|
+
you may do the following:
|
|
198
|
+
```python
|
|
199
|
+
get_waveforms(key=..., subkey=...)
|
|
200
|
+
```
|
|
201
|
+
See also the respective docs (ADXCodec, HCACodec) for more details.
|
|
202
|
+
"""
|
|
203
|
+
CODEC_TABLE = {
|
|
204
|
+
AcbEncodeTypes.ADX: ADXCodec,
|
|
205
|
+
AcbEncodeTypes.HCA: HCACodec,
|
|
206
|
+
AcbEncodeTypes.HCAMX: HCACodec,
|
|
207
|
+
}
|
|
208
|
+
awb = self.awb
|
|
209
|
+
wavs = []
|
|
210
|
+
for wav in self.view.WaveformTable:
|
|
211
|
+
encode = AcbEncodeTypes(wav.EncodeType)
|
|
212
|
+
codec = (CODEC_TABLE.get(encode, None))
|
|
213
|
+
if codec:
|
|
214
|
+
wavs.append(codec(awb.get_file_at(wav.MemoryAwbId), **kwargs))
|
|
215
|
+
else:
|
|
216
|
+
wavs.append((encode, wav.NumChannels, wav.NumSamples, wav.SamplingRate, awb.get_file_at(wav.MemoryAwbId)))
|
|
217
|
+
return wavs
|
|
218
|
+
|
|
219
|
+
def set_waveforms(self, value: List[HCACodec | ADXCodec | Tuple[AcbEncodeTypes, int, int, int, bytes]]):
|
|
220
|
+
"""Sets the waveform data.
|
|
221
|
+
|
|
222
|
+
Input item may be a codec (if known), or a tuple of (Codec ID, Channel Count, Sample Count, Sample Rate, Raw data).
|
|
223
|
+
|
|
224
|
+
NOTE: Cue duration is not set. You need to change that manually - this is usually unecessary as the player will just play until the end of the waveform.
|
|
225
|
+
"""
|
|
226
|
+
WAVEFORM = self.view.WaveformTable[0]._payload.copy()
|
|
227
|
+
encoded = []
|
|
228
|
+
tables = self.view.WaveformTable
|
|
229
|
+
tables.clear()
|
|
230
|
+
for i, codec in enumerate(value):
|
|
231
|
+
if type(codec) == HCACodec:
|
|
232
|
+
encoded.append(codec.get_encoded())
|
|
233
|
+
tables.append(WaveformTable(WAVEFORM.copy()))
|
|
234
|
+
entry = tables[-1]
|
|
235
|
+
entry.EncodeType = AcbEncodeTypes.HCA.value
|
|
236
|
+
entry.NumChannels = codec.chnls
|
|
237
|
+
entry.NumSamples = codec.total_samples
|
|
238
|
+
entry.SamplingRate = codec.sampling_rate
|
|
239
|
+
elif type(codec) == ADXCodec:
|
|
240
|
+
encoded.append(codec.get_encoded())
|
|
241
|
+
tables.append(WaveformTable(WAVEFORM.copy()))
|
|
242
|
+
entry = tables[-1]
|
|
243
|
+
entry.EncodeType = AcbEncodeTypes.ADX.value
|
|
244
|
+
entry.NumChannels = codec.chnls
|
|
245
|
+
entry.NumSamples = codec.total_samples
|
|
246
|
+
entry.SamplingRate = codec.sampling_rate
|
|
247
|
+
elif isinstance(codec, tuple):
|
|
248
|
+
e_type, e_channels, e_samples, e_rate, e_data = codec
|
|
249
|
+
encoded.append(e_data)
|
|
250
|
+
tables.append(WaveformTable(WAVEFORM.copy()))
|
|
251
|
+
entry = tables[-1]
|
|
252
|
+
entry.EncodeType = e_type.value
|
|
253
|
+
entry.NumChannels = e_channels
|
|
254
|
+
entry.NumSamples = e_samples
|
|
255
|
+
entry.SamplingRate = e_rate
|
|
256
|
+
else:
|
|
257
|
+
raise TypeError(f"Unsupported codec type: {type(codec)}")
|
|
258
|
+
tables[-1].MemoryAwbId = i
|
|
259
|
+
awb = self.awb
|
|
260
|
+
self.view.AwbFile = AWBBuilder(encoded, awb.subkey, awb.version, align=awb.align).build()
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def cues(self) -> Generator[PackedCueItem, None, None]:
|
|
265
|
+
"""Returns a generator of **read-only** Cues.
|
|
266
|
+
|
|
267
|
+
Cues reference waveform bytes by their AWB IDs, which can be accessed via `waveforms`.
|
|
268
|
+
To modify cues, use the `view` property instead.
|
|
269
|
+
"""
|
|
270
|
+
for name, cue in zip(self.view.CueNameTable, self.view.CueTable):
|
|
271
|
+
waveforms = self.view.waveform_of(cue.CueId)
|
|
272
|
+
yield PackedCueItem(cue.CueId, name.CueName, cue.Length / 1000.0, [waveform.MemoryAwbId for waveform in waveforms])
|
|
273
|
+
|
|
274
|
+
class ACBBuilder:
|
|
275
|
+
"""Use this class to build ACB files from an existing ACB object."""
|
|
276
|
+
acb: ACB
|
|
277
|
+
|
|
278
|
+
def __init__(self, acb: ACB) -> None:
|
|
279
|
+
"""Initializes the ACBBuilder with an existing ACB object.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
acb (ACB): The ACB object to build from.
|
|
283
|
+
|
|
284
|
+
Building ACB from scratch isn't planned for now since:
|
|
285
|
+
|
|
286
|
+
* We don't know how SeqCommandTable TLVs work. This is the biggest issue.
|
|
287
|
+
* Many fields are unknown or not well understood
|
|
288
|
+
- Games may expect AcfReferenceTable, Asiac stuff etc to be present for their own assets in conjunction
|
|
289
|
+
with their own ACF table. Missing these is not a fun debugging experience.
|
|
290
|
+
* ACB tables differ a LOT from game to game (e.g. Lipsync info), contary to USM formats.
|
|
291
|
+
|
|
292
|
+
Maybe one day I'll get around to this. But otherwise starting from nothing is a WONTFIX for now.
|
|
293
|
+
"""
|
|
294
|
+
self.acb = acb
|
|
295
|
+
|
|
296
|
+
def build(self) -> bytes:
|
|
297
|
+
"""Builds an ACB binary blob from the current ACB object.
|
|
298
|
+
|
|
299
|
+
The object may be modified in place before building, which will be reflected in the output binary.
|
|
300
|
+
"""
|
|
301
|
+
# Check whether all AWB indices are valid
|
|
302
|
+
assert all(
|
|
303
|
+
waveform.MemoryAwbId < self.acb.awb.numfiles for waveform in self.acb.view.WaveformTable
|
|
304
|
+
), "one or more AWB indices are out of range"
|
|
305
|
+
binary = UTFBuilder(self.acb.dictarray, encoding=self.acb.encoding, table_name=self.acb.table_name)
|
|
306
|
+
return binary.bytes()
|
PyCriCodecsEx/adx.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from typing import BinaryIO
|
|
2
|
+
from io import BytesIO
|
|
3
|
+
from PyCriCodecsEx.chunk import *
|
|
4
|
+
import CriCodecsEx
|
|
5
|
+
class ADX:
|
|
6
|
+
"""ADX class for decoding and encoding ADX files, pass the either `adx file` or `wav file` in bytes to either `decode` or `encode` respectively.
|
|
7
|
+
|
|
8
|
+
**NOTE:** Direct usage of this class is not recommended, use the `ADXCodec` wrapper instead.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Decodes ADX to WAV.
|
|
12
|
+
@staticmethod
|
|
13
|
+
def decode(data: bytes) -> bytes:
|
|
14
|
+
""" Decodes ADX to WAV. """
|
|
15
|
+
return CriCodecsEx.AdxDecode(bytes(data))
|
|
16
|
+
|
|
17
|
+
# Encodes WAV to ADX.
|
|
18
|
+
@staticmethod
|
|
19
|
+
def encode(data: bytes, BitDepth = 0x4, Blocksize = 0x12, Encoding = 3, AdxVersion = 0x4, Highpass_Frequency = 0x1F4, Filter = 0, force_not_looping = False) -> bytes:
|
|
20
|
+
""" Encodes WAV to ADX. """
|
|
21
|
+
return CriCodecsEx.AdxEncode(bytes(data), BitDepth, Blocksize, Encoding, Highpass_Frequency, Filter, AdxVersion, force_not_looping)
|
|
22
|
+
|
|
23
|
+
class ADXCodec(ADX):
|
|
24
|
+
"""Use this class for encoding and decoding ADX files, from and to WAV."""
|
|
25
|
+
|
|
26
|
+
CHUNK_INTERVAL = 99.9
|
|
27
|
+
BASE_FRAMERATE = 2997
|
|
28
|
+
# TODO: Move these to an enum
|
|
29
|
+
AUDIO_CODEC = 2
|
|
30
|
+
METADATA_COUNT = 0
|
|
31
|
+
|
|
32
|
+
filename : str
|
|
33
|
+
filesize : int
|
|
34
|
+
|
|
35
|
+
adx : bytes
|
|
36
|
+
header : bytes
|
|
37
|
+
sfaStream: BinaryIO
|
|
38
|
+
|
|
39
|
+
AdxDataOffset: int
|
|
40
|
+
AdxEncoding: int
|
|
41
|
+
AdxBlocksize: int
|
|
42
|
+
AdxSampleBitdepth: int
|
|
43
|
+
AdxChannelCount: int
|
|
44
|
+
AdxSamplingRate: int
|
|
45
|
+
AdxSampleCount: int
|
|
46
|
+
AdxHighpassFrequency: int
|
|
47
|
+
AdxVersion: int
|
|
48
|
+
AdxFlags: int
|
|
49
|
+
|
|
50
|
+
chnls: int
|
|
51
|
+
sampling_rate: int
|
|
52
|
+
total_samples: int
|
|
53
|
+
avbps: int
|
|
54
|
+
|
|
55
|
+
def __init__(self, stream: str | bytes, filename: str = "default.adx", bitdepth: int = 4, **kwargs):
|
|
56
|
+
"""Initializes the ADX encoder/decoder
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
stream (str | bytes): Path to the ADX or WAV file, or a BinaryIO stream. WAV files will be automatically encoded with the given settings first.
|
|
60
|
+
filename (str, optional): Filename, used by USMBuilder. Defaults to "default.adx".
|
|
61
|
+
bitdepth (int, optional): Audio bit depth within [2,15]. Defaults to 4.
|
|
62
|
+
"""
|
|
63
|
+
if type(stream) == str:
|
|
64
|
+
self.adx = open(stream, "rb").read()
|
|
65
|
+
else:
|
|
66
|
+
self.adx = stream
|
|
67
|
+
self.filename = filename
|
|
68
|
+
self.filesize = len(self.adx)
|
|
69
|
+
magic = self.adx[:4]
|
|
70
|
+
if magic == b"RIFF":
|
|
71
|
+
self.adx = self.encode(self.adx, bitdepth, force_not_looping=True)
|
|
72
|
+
self.sfaStream = BytesIO(self.adx)
|
|
73
|
+
header = AdxHeaderStruct.unpack(self.sfaStream.read(AdxHeaderStruct.size))
|
|
74
|
+
FourCC, self.AdxDataOffset, self.AdxEncoding, self.AdxBlocksize, self.AdxSampleBitdepth, self.AdxChannelCount, self.AdxSamplingRate, self.AdxSampleCount, self.AdxHighpassFrequency, self.AdxVersion, self.AdxFlags = header
|
|
75
|
+
assert FourCC == 0x8000, "either ADX or WAV is supported"
|
|
76
|
+
assert self.AdxVersion in {3,4}, "unsupported ADX version"
|
|
77
|
+
if self.AdxVersion == 4:
|
|
78
|
+
self.sfaStream.seek(4 + 4 * self.AdxChannelCount, 1) # Padding + Hist values, they always seem to be 0.
|
|
79
|
+
self.sfaStream.seek(0)
|
|
80
|
+
self.chnls = self.AdxChannelCount
|
|
81
|
+
self.sampling_rate = self.AdxSamplingRate
|
|
82
|
+
self.total_samples = self.AdxSampleCount
|
|
83
|
+
self.avbps = int(self.filesize * 8 * self.chnls) - self.filesize
|
|
84
|
+
|
|
85
|
+
def generate_SFA(self, index: int, builder):
|
|
86
|
+
# USMBuilder usage
|
|
87
|
+
current_interval = 0
|
|
88
|
+
stream_size = len(self.adx) - self.AdxBlocksize
|
|
89
|
+
chunk_size = int(self.AdxSamplingRate // (self.BASE_FRAMERATE / 100) // 32) * (self.AdxBlocksize * self.AdxChannelCount)
|
|
90
|
+
self.sfaStream.seek(0)
|
|
91
|
+
res = []
|
|
92
|
+
while self.sfaStream.tell() < stream_size:
|
|
93
|
+
if self.sfaStream.tell() > 0:
|
|
94
|
+
if self.sfaStream.tell() + chunk_size < stream_size:
|
|
95
|
+
datalen = chunk_size
|
|
96
|
+
else:
|
|
97
|
+
datalen = (stream_size - (self.AdxDataOffset + 4) - chunk_size) % chunk_size
|
|
98
|
+
else:
|
|
99
|
+
datalen = self.AdxDataOffset + 4
|
|
100
|
+
if not datalen:
|
|
101
|
+
break
|
|
102
|
+
padding = (0x20 - (datalen % 0x20) if datalen % 0x20 != 0 else 0)
|
|
103
|
+
SFA_chunk = USMChunkHeader.pack(
|
|
104
|
+
USMChunckHeaderType.SFA.value,
|
|
105
|
+
datalen + 0x18 + padding,
|
|
106
|
+
0,
|
|
107
|
+
0x18,
|
|
108
|
+
padding,
|
|
109
|
+
index,
|
|
110
|
+
0,
|
|
111
|
+
0,
|
|
112
|
+
0,
|
|
113
|
+
round(current_interval),
|
|
114
|
+
self.BASE_FRAMERATE,
|
|
115
|
+
0,
|
|
116
|
+
0
|
|
117
|
+
)
|
|
118
|
+
chunk_data = self.sfaStream.read(datalen)
|
|
119
|
+
if builder.encrypt_audio:
|
|
120
|
+
SFA_chunk = builder.AudioMask(chunk_data)
|
|
121
|
+
SFA_chunk += chunk_data.ljust(datalen + padding, b"\x00")
|
|
122
|
+
current_interval += self.CHUNK_INTERVAL
|
|
123
|
+
res.append(SFA_chunk)
|
|
124
|
+
# ---
|
|
125
|
+
SFA_chunk = USMChunkHeader.pack(
|
|
126
|
+
USMChunckHeaderType.SFA.value,
|
|
127
|
+
0x38,
|
|
128
|
+
0,
|
|
129
|
+
0x18,
|
|
130
|
+
0,
|
|
131
|
+
index,
|
|
132
|
+
0,
|
|
133
|
+
0,
|
|
134
|
+
2,
|
|
135
|
+
0,
|
|
136
|
+
30,
|
|
137
|
+
0,
|
|
138
|
+
0
|
|
139
|
+
)
|
|
140
|
+
SFA_chunk += b"#CONTENTS END ===============\x00"
|
|
141
|
+
res[-1] += SFA_chunk
|
|
142
|
+
return res
|
|
143
|
+
|
|
144
|
+
def get_metadata(self):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def get_encoded(self) -> bytes:
|
|
148
|
+
"""Gets the encoded ADX audio data."""
|
|
149
|
+
return self.adx
|
|
150
|
+
|
|
151
|
+
def save(self, filepath: str | BinaryIO):
|
|
152
|
+
"""Saves the decoded WAV audio to filepath or a writable stream"""
|
|
153
|
+
if type(filepath) == str:
|
|
154
|
+
with open(filepath, "wb") as f:
|
|
155
|
+
f.write(self.decode(self.adx))
|
|
156
|
+
else:
|
|
157
|
+
filepath.write(self.decode(self.adx))
|
|
158
|
+
|
PyCriCodecsEx/awb.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from io import BytesIO, FileIO
|
|
2
|
+
from typing import BinaryIO, Generator
|
|
3
|
+
from struct import iter_unpack, pack
|
|
4
|
+
from PyCriCodecsEx.chunk import *
|
|
5
|
+
from PyCriCodecsEx.hca import HCA
|
|
6
|
+
|
|
7
|
+
# for AFS2 only.
|
|
8
|
+
class AWB:
|
|
9
|
+
""" Use this class to return any AWB data with the getfiles function. """
|
|
10
|
+
stream: BinaryIO
|
|
11
|
+
numfiles: int
|
|
12
|
+
align: int
|
|
13
|
+
subkey: bytes
|
|
14
|
+
version: int
|
|
15
|
+
ids: list
|
|
16
|
+
ofs: list
|
|
17
|
+
filename: str
|
|
18
|
+
headersize: int
|
|
19
|
+
id_alignment: int
|
|
20
|
+
|
|
21
|
+
def __init__(self, stream : str | BinaryIO) -> None:
|
|
22
|
+
"""Initializes the AWB object
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
stream (str | BinaryIO): Source file path or binary stream
|
|
26
|
+
"""
|
|
27
|
+
if type(stream) == str:
|
|
28
|
+
self.stream = FileIO(stream)
|
|
29
|
+
self.filename = stream
|
|
30
|
+
else:
|
|
31
|
+
self.stream = BytesIO(stream)
|
|
32
|
+
self.filename = ""
|
|
33
|
+
self._readheader()
|
|
34
|
+
|
|
35
|
+
def _readheader(self):
|
|
36
|
+
# Reads header.
|
|
37
|
+
magic, self.version, offset_intsize, self.id_intsize, self.numfiles, self.align, self.subkey = AWBChunkHeader.unpack(
|
|
38
|
+
self.stream.read(AWBChunkHeader.size)
|
|
39
|
+
)
|
|
40
|
+
if magic != b'AFS2':
|
|
41
|
+
raise ValueError("Invalid AWB header.")
|
|
42
|
+
|
|
43
|
+
# Reads data in the header.
|
|
44
|
+
self.ids = list()
|
|
45
|
+
self.ofs = list()
|
|
46
|
+
for i in iter_unpack(f"<{self._stringtypes(self.id_intsize)}", self.stream.read(self.id_intsize*self.numfiles)):
|
|
47
|
+
self.ids.append(i[0])
|
|
48
|
+
for i in iter_unpack(f"<{self._stringtypes(offset_intsize)}", self.stream.read(offset_intsize*(self.numfiles+1))):
|
|
49
|
+
self.ofs.append(i[0] if i[0] % self.align == 0 else (i[0] + (self.align - (i[0] % self.align))))
|
|
50
|
+
|
|
51
|
+
# Seeks to files offset.
|
|
52
|
+
self.headersize = 16 + (offset_intsize*(self.numfiles+1)) + (self.id_intsize*self.numfiles)
|
|
53
|
+
if self.headersize % self.align != 0:
|
|
54
|
+
self.headersize = self.headersize + (self.align - (self.headersize % self.align))
|
|
55
|
+
self.stream.seek(self.headersize, 0)
|
|
56
|
+
|
|
57
|
+
def get_files(self) -> Generator[bytes, None, None]:
|
|
58
|
+
"""Generator function to yield all data blobs from an AWB. """
|
|
59
|
+
self.stream.seek(self.headersize, 0)
|
|
60
|
+
for i in range(1, len(self.ofs)):
|
|
61
|
+
data = self.stream.read((self.ofs[i]-self.ofs[i-1]))
|
|
62
|
+
self.stream.seek(self.ofs[i], 0)
|
|
63
|
+
yield data
|
|
64
|
+
|
|
65
|
+
def get_file_at(self, index) -> bytes:
|
|
66
|
+
"""Gets you a file at specific index. """
|
|
67
|
+
self.stream.seek(self.ofs[index], 0)
|
|
68
|
+
data = self.stream.read(self.ofs[index + 1]-self.ofs[index])
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
def _stringtypes(self, intsize: int) -> str:
|
|
72
|
+
if intsize == 1:
|
|
73
|
+
return "B" # Probably impossible.
|
|
74
|
+
elif intsize == 2:
|
|
75
|
+
return "H"
|
|
76
|
+
elif intsize == 4:
|
|
77
|
+
return "I"
|
|
78
|
+
elif intsize == 8:
|
|
79
|
+
return "Q"
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("Unknown int size.")
|
|
82
|
+
|
|
83
|
+
class AWBBuilder:
|
|
84
|
+
"""Use this class to build AWB files from a list of bytes."""
|
|
85
|
+
def __init__(self, infiles: list[bytes], subkey: int = 0, version: int = 2, id_intsize = 0x2, align: int = 0x20) -> None:
|
|
86
|
+
"""Initializes the AWB builder.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
infiles (list[bytes]): List of bytes to be included in the AWB file.
|
|
90
|
+
subkey (int, optional): AWB subkey. Defaults to 0.
|
|
91
|
+
version (int, optional): AWB version. Defaults to 2.
|
|
92
|
+
id_intsize (hexadecimal, optional): Integer size (in bytes) for string lengths. Defaults to 0x2.
|
|
93
|
+
align (int, optional): Alignment. Defaults to 0x20.
|
|
94
|
+
"""
|
|
95
|
+
if version == 1 and subkey != 0:
|
|
96
|
+
raise ValueError("Cannot have a subkey with AWB version of 1.")
|
|
97
|
+
elif id_intsize not in [0x2, 0x4, 0x8]:
|
|
98
|
+
raise ValueError("id_intsize must be either 2, 4 or 8.")
|
|
99
|
+
self.infiles = infiles
|
|
100
|
+
self.version = version
|
|
101
|
+
self.align = align
|
|
102
|
+
self.subkey = subkey
|
|
103
|
+
self.id_intsize = id_intsize
|
|
104
|
+
|
|
105
|
+
def _stringtypes(self, intsize: int) -> str:
|
|
106
|
+
if intsize == 1:
|
|
107
|
+
return "B" # Probably impossible.
|
|
108
|
+
elif intsize == 2:
|
|
109
|
+
return "H"
|
|
110
|
+
elif intsize == 4:
|
|
111
|
+
return "I"
|
|
112
|
+
elif intsize == 8:
|
|
113
|
+
return "Q"
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError("Unknown int size.")
|
|
116
|
+
|
|
117
|
+
def build(self) -> bytes:
|
|
118
|
+
"""Builds the AWB file from the provided infiles bytes."""
|
|
119
|
+
size = 0
|
|
120
|
+
ofs = []
|
|
121
|
+
numfiles = 0
|
|
122
|
+
for file in self.infiles:
|
|
123
|
+
sz = len(file)
|
|
124
|
+
ofs.append(size+sz)
|
|
125
|
+
size += sz
|
|
126
|
+
numfiles += 1
|
|
127
|
+
|
|
128
|
+
if size > 0xFFFFFFFF:
|
|
129
|
+
intsize = 8 # Unsigned long long.
|
|
130
|
+
strtype = "<Q"
|
|
131
|
+
else:
|
|
132
|
+
intsize = 4 # Unsigned int, but could be a ushort, never saw it as one before though.
|
|
133
|
+
strtype = "<I"
|
|
134
|
+
|
|
135
|
+
header = AWBChunkHeader.pack(
|
|
136
|
+
b'AFS2', self.version, intsize, self.id_intsize, numfiles, self.align, self.subkey
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
id_strsize = f"<{self._stringtypes(self.id_intsize)}"
|
|
140
|
+
for i in range(numfiles):
|
|
141
|
+
header += pack(id_strsize, i)
|
|
142
|
+
|
|
143
|
+
headersize = len(header) + intsize * numfiles + intsize
|
|
144
|
+
aligned_header_size = headersize + (self.align - (headersize % self.align))
|
|
145
|
+
ofs2 = []
|
|
146
|
+
for idx, x in enumerate(ofs):
|
|
147
|
+
if (x+aligned_header_size) % self.align != 0 and idx != len(ofs) - 1:
|
|
148
|
+
ofs2.append((x+aligned_header_size) + (self.align - ((x+aligned_header_size) % self.align)))
|
|
149
|
+
else:
|
|
150
|
+
ofs2.append(x+aligned_header_size)
|
|
151
|
+
ofs = [headersize] + ofs2
|
|
152
|
+
|
|
153
|
+
for i in ofs:
|
|
154
|
+
header += pack(strtype, i)
|
|
155
|
+
|
|
156
|
+
if headersize % self.align != 0:
|
|
157
|
+
header = header.ljust(headersize + (self.align - (headersize % self.align)), b"\x00")
|
|
158
|
+
outfile = BytesIO()
|
|
159
|
+
outfile.write(header)
|
|
160
|
+
for idx, file in enumerate(self.infiles):
|
|
161
|
+
fl = file
|
|
162
|
+
if len(fl) % self.align != 0 and idx != len(self.infiles) - 1:
|
|
163
|
+
fl = fl.ljust(len(fl) + (self.align - (len(fl) % self.align)), b"\x00")
|
|
164
|
+
outfile.write(fl)
|
|
165
|
+
return outfile.getvalue()
|