PyCriCodecsEx 0.0.2__tar.gz → 0.0.3__tar.gz
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.
Potentially problematic release.
This version of PyCriCodecsEx might be problematic. Click here for more details.
- pycricodecsex-0.0.3/PKG-INFO +35 -0
- pycricodecsex-0.0.3/PyCriCodecsEx/__init__.py +1 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/acb.py +21 -10
- pycricodecsex-0.0.3/PyCriCodecsEx/adx.py +155 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/awb.py +1 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/chunk.py +0 -5
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/cpk.py +16 -12
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/hca.py +167 -27
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/usm.py +18 -258
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx/utf.py +8 -24
- pycricodecsex-0.0.3/PyCriCodecsEx.egg-info/PKG-INFO +35 -0
- pycricodecsex-0.0.3/README.md +13 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/setup.py +1 -1
- pycricodecsex-0.0.2/PKG-INFO +0 -86
- pycricodecsex-0.0.2/PyCriCodecsEx/__init__.py +0 -1
- pycricodecsex-0.0.2/PyCriCodecsEx/adx.py +0 -16
- pycricodecsex-0.0.2/PyCriCodecsEx.egg-info/PKG-INFO +0 -86
- pycricodecsex-0.0.2/README.md +0 -64
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/CriCodecsEx.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/IO.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/IO.hpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/adx.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/crilayla.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/hca.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/hca.h +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/CriCodecsEx/pcm.cpp +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/LICENSE +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx.egg-info/SOURCES.txt +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx.egg-info/dependency_links.txt +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx.egg-info/requires.txt +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/PyCriCodecsEx.egg-info/top_level.txt +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/pyproject.toml +0 -0
- {pycricodecsex-0.0.2 → pycricodecsex-0.0.3}/setup.cfg +0 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: PyCriCodecsEx
|
|
3
|
+
Version: 0.0.3
|
|
4
|
+
Summary: Criware formats library for Python
|
|
5
|
+
Home-page: https://mos9527.github.io/PyCriCodecsEx/
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Provides-Extra: usm
|
|
13
|
+
Requires-Dist: ffmpeg-python; extra == "usm"
|
|
14
|
+
Dynamic: classifier
|
|
15
|
+
Dynamic: description
|
|
16
|
+
Dynamic: description-content-type
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
Dynamic: provides-extra
|
|
20
|
+
Dynamic: requires-python
|
|
21
|
+
Dynamic: summary
|
|
22
|
+
|
|
23
|
+
PyCriCodecsEx
|
|
24
|
+
---
|
|
25
|
+
A continuation of @Youjose's work on Criware formats. Feautres are still in flux and subject to change. When in doubt, Refer to the [original repo](https://github.com/Youjose/PyCriCodecs) for more information.
|
|
26
|
+
|
|
27
|
+
Detailed documentation, installation instructions are available at https://mos9527.com/PyCriCodecsEx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Credits
|
|
31
|
+
- https://github.com/Youjose/PyCriCodecs
|
|
32
|
+
- https://github.com/Mikewando/PyCriCodecs ([PR#1 on USM](https://github.com/mos9527/PyCriCodecsEx/pull/1))
|
|
33
|
+
- https://github.com/donmai-me/WannaCRI
|
|
34
|
+
- https://github.com/vgmstream/vgmstream
|
|
35
|
+
- https://github.com/K0lb3/UnityPy (For CI script)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.3"
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
# - Original work by https://github.com/Youjose/PyCriCodecs
|
|
4
4
|
# See Research/ACBSchema.py for more details.
|
|
5
5
|
|
|
6
|
-
from typing import Generator, List, Tuple
|
|
6
|
+
from typing import Generator, List, Tuple, BinaryIO
|
|
7
7
|
from PyCriCodecsEx.chunk import *
|
|
8
8
|
from PyCriCodecsEx.utf import UTF, UTFBuilder, UTFViewer
|
|
9
|
-
from PyCriCodecsEx.
|
|
9
|
+
from PyCriCodecsEx.hca import HCACodec
|
|
10
|
+
from PyCriCodecsEx.adx import ADXCodec
|
|
10
11
|
from PyCriCodecsEx.awb import AWB, AWBBuilder
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from copy import deepcopy
|
|
@@ -136,9 +137,14 @@ class CueItem:
|
|
|
136
137
|
Waveforms: list[int] # List of waveform IDs
|
|
137
138
|
|
|
138
139
|
class ACB(UTF):
|
|
139
|
-
"""
|
|
140
|
-
def __init__(self,
|
|
141
|
-
|
|
140
|
+
"""Use this class to read, and modify ACB files in memory."""
|
|
141
|
+
def __init__(self, stream : str | BinaryIO) -> None:
|
|
142
|
+
"""Loads an ACB file from the given stream.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
stream (str | BinaryIO): The path to the ACB file or a BinaryIO stream containing the ACB data.
|
|
146
|
+
"""
|
|
147
|
+
super().__init__(stream, recursive=True)
|
|
142
148
|
|
|
143
149
|
@property
|
|
144
150
|
def payload(self) -> dict:
|
|
@@ -239,17 +245,22 @@ class ACB(UTF):
|
|
|
239
245
|
yield CueItem(cue.CueId, name.CueName, cue.Length / 1000.0, [waveform.MemoryAwbId for waveform in waveforms])
|
|
240
246
|
|
|
241
247
|
class ACBBuilder:
|
|
248
|
+
"""Use this class to build ACB files from an existing ACB object."""
|
|
242
249
|
acb: ACB
|
|
243
250
|
|
|
244
251
|
def __init__(self, acb: ACB) -> None:
|
|
245
252
|
"""Initializes the ACBBuilder with an existing ACB object.
|
|
246
253
|
|
|
254
|
+
Args:
|
|
255
|
+
acb (ACB): The ACB object to build from.
|
|
256
|
+
|
|
247
257
|
Building ACB from scratch isn't planned for now since:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
258
|
+
|
|
259
|
+
* We don't know how SeqCommandTable TLVs work. This is the biggest issue.
|
|
260
|
+
* Many fields are unknown or not well understood
|
|
261
|
+
- Games may expect AcfReferenceTable, Asiac stuff etc to be present for their own assets in conjunction
|
|
262
|
+
with their own ACF table. Missing these is not a fun debugging experience.
|
|
263
|
+
* ACB tables differ a LOT from game to game (e.g. Lipsync info), contary to USM formats.
|
|
253
264
|
|
|
254
265
|
Maybe one day I'll get around to this. But otherwise starting from nothing is a WONTFIX for now.
|
|
255
266
|
"""
|
|
@@ -0,0 +1,155 @@
|
|
|
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): Output filename. 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):
|
|
148
|
+
"""Gets the encoded ADX audio data."""
|
|
149
|
+
return self.adx
|
|
150
|
+
|
|
151
|
+
def save(self, filepath: str):
|
|
152
|
+
"""Saves the encoded ADX audio to filepath"""
|
|
153
|
+
with open(filepath, "wb") as f:
|
|
154
|
+
f.write(self.decode(self.adx))
|
|
155
|
+
|
|
@@ -82,6 +82,7 @@ class AWB:
|
|
|
82
82
|
raise ValueError("Unknown int size.")
|
|
83
83
|
|
|
84
84
|
class AWBBuilder:
|
|
85
|
+
"""Use this class to build AWB files from a list of bytes."""
|
|
85
86
|
def __init__(self, infiles: list[bytes], subkey: int = 0, version: int = 2, id_intsize = 0x2, align: int = 0x20) -> None:
|
|
86
87
|
"""Initializes the AWB builder.
|
|
87
88
|
|
|
@@ -46,11 +46,6 @@ class HCAType(Enum):
|
|
|
46
46
|
HCA = b"HCA\x00" # Header.
|
|
47
47
|
EHCA = b"\xC8\xC3\xC1\x00" # Encrypted HCA header.
|
|
48
48
|
|
|
49
|
-
class VideoType(Enum):
|
|
50
|
-
IVF = b"DKIF" # Header.
|
|
51
|
-
# H264 = b"" # Header.
|
|
52
|
-
# MPEG = b"" # Header.
|
|
53
|
-
|
|
54
49
|
# I saw some devs swap the unsigned/signed indexes. So I am not sure what's correct or not.
|
|
55
50
|
# In my own experience, swapping those results in an incorrect signed values (should be unsigned) in ACB's/CPK's.
|
|
56
51
|
# If someone were to change this, they must change 'stringtypes' function in UTF/UTFBuilder classes.
|
|
@@ -48,6 +48,7 @@ class _TOC():
|
|
|
48
48
|
self.table = UTF(self.stream.read()).table
|
|
49
49
|
|
|
50
50
|
class CPK:
|
|
51
|
+
"""Use this class to load CPK file table-of-content, and read files from them on-demand."""
|
|
51
52
|
magic: bytes
|
|
52
53
|
encflag: int
|
|
53
54
|
packet_size: int
|
|
@@ -189,7 +190,7 @@ class CPKBuilder:
|
|
|
189
190
|
ContentSize: int
|
|
190
191
|
EnabledDataSize: int
|
|
191
192
|
EnabledPackedSize: int
|
|
192
|
-
outfile:
|
|
193
|
+
outfile: BinaryIO
|
|
193
194
|
init_toc_len: int # This is a bit of a redundancy, but some CPK's need it.
|
|
194
195
|
|
|
195
196
|
in_files : list[tuple[str, str, bool]] # (source path, dest filename, compress or not)
|
|
@@ -254,14 +255,13 @@ class CPKBuilder:
|
|
|
254
255
|
|
|
255
256
|
self.in_files.append((src, dst, compress))
|
|
256
257
|
|
|
257
|
-
def _writetofile(self, header) -> None:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
self.progress_cb("Write %s" % os.path.basename(filename), i + 1, len(self.files))
|
|
258
|
+
def _writetofile(self, header) -> None:
|
|
259
|
+
self.outfile.write(header)
|
|
260
|
+
for i, ((path, _), (filename, file_size, pack_size)) in enumerate(zip(self.os_files, self.files)):
|
|
261
|
+
src = open(path, 'rb').read()
|
|
262
|
+
self.outfile.write(src)
|
|
263
|
+
self.outfile.write(bytes(0x800 - pack_size % 0x800))
|
|
264
|
+
self.progress_cb("Write %s" % os.path.basename(filename), i + 1, len(self.files))
|
|
265
265
|
|
|
266
266
|
def _populate_files(self, parallel : bool):
|
|
267
267
|
self.files = []
|
|
@@ -304,12 +304,12 @@ class CPKBuilder:
|
|
|
304
304
|
pass
|
|
305
305
|
self.os_files = []
|
|
306
306
|
|
|
307
|
-
def save(self, outfile : str, parallel : bool = False):
|
|
307
|
+
def save(self, outfile : str | BinaryIO, parallel : bool = False):
|
|
308
308
|
"""Build and save the bundle into a file
|
|
309
309
|
|
|
310
310
|
|
|
311
311
|
Args:
|
|
312
|
-
outfile (str): The output file path.
|
|
312
|
+
outfile (str | BinaryIO): The output file path or a writable binary stream.
|
|
313
313
|
parallel (bool, optional): Whether to use parallel processing for file compression (if at all used). Defaults to False.
|
|
314
314
|
|
|
315
315
|
NOTE:
|
|
@@ -318,6 +318,8 @@ class CPKBuilder:
|
|
|
318
318
|
"""
|
|
319
319
|
assert self.in_files, "cannot save empty bundle"
|
|
320
320
|
self.outfile = outfile
|
|
321
|
+
if type(outfile) == str:
|
|
322
|
+
self.outfile = open(outfile, "wb")
|
|
321
323
|
self._populate_files(parallel)
|
|
322
324
|
if self.encrypt:
|
|
323
325
|
encflag = 0
|
|
@@ -363,7 +365,9 @@ class CPKBuilder:
|
|
|
363
365
|
data = self.CPKdata.ljust(len(self.CPKdata) + (0x800 - len(self.CPKdata) % 0x800) - 6, b'\x00') + bytearray(b"(c)CRI") + self.ITOCdata
|
|
364
366
|
self._writetofile(data)
|
|
365
367
|
self._cleanup_files()
|
|
366
|
-
|
|
368
|
+
if type(outfile) == str:
|
|
369
|
+
self.outfile.close()
|
|
370
|
+
|
|
367
371
|
def _generate_GTOC(self) -> bytearray:
|
|
368
372
|
# NOTE: Practically useless
|
|
369
373
|
# I have no idea why are those numbers here.
|
|
@@ -5,7 +5,7 @@ from array import array
|
|
|
5
5
|
import CriCodecsEx
|
|
6
6
|
|
|
7
7
|
from PyCriCodecsEx.chunk import *
|
|
8
|
-
|
|
8
|
+
from PyCriCodecsEx.utf import UTFTypeValues, UTFBuilder
|
|
9
9
|
HcaHeaderStruct = Struct(">4sHH")
|
|
10
10
|
HcaFmtHeaderStruct = Struct(">4sIIHH")
|
|
11
11
|
HcaCompHeaderStruct = Struct(">4sHBBBBBBBBBB")
|
|
@@ -17,6 +17,10 @@ HcaCiphHeaderStruct = Struct(">4sH")
|
|
|
17
17
|
HcaRvaHeaderStruct = Struct(">4sf")
|
|
18
18
|
|
|
19
19
|
class HCA:
|
|
20
|
+
"""HCA class for decoding and encoding HCA files
|
|
21
|
+
|
|
22
|
+
**NOTE:** Direct usage of this class is not recommended, use the `HCACodec` wrapper instead.
|
|
23
|
+
"""
|
|
20
24
|
stream: BinaryIO
|
|
21
25
|
hcastream: BinaryIO
|
|
22
26
|
HcaSig: bytes
|
|
@@ -205,32 +209,28 @@ class HCA:
|
|
|
205
209
|
raise ValueError(f"WAV bitdepth of {self.fmtBitCount} is not supported, only 16 bit WAV files are supported.")
|
|
206
210
|
elif self.fmtSize != 16:
|
|
207
211
|
raise ValueError(f"WAV file has an FMT chunk of an unsupported size: {self.fmtSize}, the only supported size is 16.")
|
|
208
|
-
|
|
209
|
-
self.stream.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
self.
|
|
230
|
-
self.stream.read(WavDataHeaderStruct.size)
|
|
231
|
-
)
|
|
232
|
-
else:
|
|
233
|
-
raise ValueError("Invalid or an unsupported wav file.")
|
|
212
|
+
while (hdr := self.stream.read(4)):
|
|
213
|
+
size = int.from_bytes(self.stream.read(4), 'little')
|
|
214
|
+
size += (size & 1) # padding
|
|
215
|
+
offset = self.stream.tell()
|
|
216
|
+
match hdr:
|
|
217
|
+
case b"smpl":
|
|
218
|
+
self.stream.seek(-4, 1)
|
|
219
|
+
self.looping = True
|
|
220
|
+
# Will just be naming the important things here.
|
|
221
|
+
smplsig, smplesize, _, _, _, _, _, _, _, self.LoopCount, _, _, _, self.LoopStartSample, self.LoopEndSample, _, _ = WavSmplHeaderStruct.unpack(
|
|
222
|
+
self.stream.read(WavSmplHeaderStruct.size)
|
|
223
|
+
)
|
|
224
|
+
if self.LoopCount != 1:
|
|
225
|
+
self.looping = False # Unsupported multiple looping points, so backtracks, and ignores looping data.
|
|
226
|
+
self.stream.seek(-WavSmplHeaderStruct.size, 1)
|
|
227
|
+
self.stream.seek(8 + smplesize, 1)
|
|
228
|
+
case b"data":
|
|
229
|
+
self.stream.seek(-4, 1)
|
|
230
|
+
self.dataSig, self.dataSize = WavDataHeaderStruct.unpack(
|
|
231
|
+
self.stream.read(WavDataHeaderStruct.size)
|
|
232
|
+
)
|
|
233
|
+
self.stream.seek(offset + size, 0)
|
|
234
234
|
else:
|
|
235
235
|
raise ValueError("Invalid HCA or WAV file.")
|
|
236
236
|
self.stream.seek(0)
|
|
@@ -309,3 +309,143 @@ class HCA:
|
|
|
309
309
|
header = self.hcastream.read(self.header_size)
|
|
310
310
|
self.hcastream.seek(0)
|
|
311
311
|
return header
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class HCACodec(HCA):
|
|
315
|
+
"""Use this class for encoding and decoding HCA files, from and to WAV."""
|
|
316
|
+
CHUNK_INTERVAL = 64
|
|
317
|
+
BASE_FRAMERATE = 2997 # dt = CHUNK_INTERVAL / BASE_FRAMERATE
|
|
318
|
+
AUDIO_CODEC = 4
|
|
319
|
+
METADATA_COUNT = 1
|
|
320
|
+
|
|
321
|
+
filename: str
|
|
322
|
+
|
|
323
|
+
chnls: int
|
|
324
|
+
sampling_rate: int
|
|
325
|
+
total_samples: int
|
|
326
|
+
avbps: int
|
|
327
|
+
|
|
328
|
+
filesize: int
|
|
329
|
+
|
|
330
|
+
def __init__(self, stream: str | bytes, filename: str = "default.hca", quality: CriHcaQuality = CriHcaQuality.High, key=0, subkey=0, **kwargs):
|
|
331
|
+
"""Initializes the HCA encoder/decoder
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
stream (str | bytes): Path to the HCA or WAV file, or a BinaryIO stream. WAV files will be automatically encoded with the given settings first.
|
|
335
|
+
filename (str, optional): USM filename. Defaults to "default.hca".
|
|
336
|
+
quality (CriHcaQuality, optional): Encoding quality. Defaults to CriHcaQuality.High.
|
|
337
|
+
key (int, optional): HCA key. Defaults to 0.
|
|
338
|
+
subkey (int, optional): HCA subkey. Defaults to 0.
|
|
339
|
+
"""
|
|
340
|
+
self.filename = filename
|
|
341
|
+
super().__init__(stream, key, subkey)
|
|
342
|
+
if self.filetype == "wav":
|
|
343
|
+
self.encode(
|
|
344
|
+
force_not_looping=True,
|
|
345
|
+
encrypt=key != 0,
|
|
346
|
+
keyless=False,
|
|
347
|
+
quality_level=quality
|
|
348
|
+
)
|
|
349
|
+
self.hcastream.seek(0, 2)
|
|
350
|
+
self.filesize = self.hcastream.tell()
|
|
351
|
+
self.hcastream.seek(0)
|
|
352
|
+
|
|
353
|
+
if self.filetype == "wav":
|
|
354
|
+
self.chnls = self.fmtChannelCount
|
|
355
|
+
self.sampling_rate = self.fmtSamplingRate
|
|
356
|
+
self.total_samples = int(self.dataSize // self.fmtSamplingSize)
|
|
357
|
+
else:
|
|
358
|
+
self.chnls = self.hca["ChannelCount"]
|
|
359
|
+
self.sampling_rate = self.hca["SampleRate"]
|
|
360
|
+
self.total_samples = self.hca["FrameCount"]
|
|
361
|
+
# I don't know how this is derived so I am putting my best guess here. TODO
|
|
362
|
+
self.avbps = int(self.filesize / self.chnls)
|
|
363
|
+
|
|
364
|
+
def generate_SFA(self, index: int, builder):
|
|
365
|
+
# USMBuilder usage
|
|
366
|
+
current_interval = 0
|
|
367
|
+
padding = (
|
|
368
|
+
0x20 - (self.hca["HeaderSize"] % 0x20)
|
|
369
|
+
if self.hca["HeaderSize"] % 0x20 != 0
|
|
370
|
+
else 0
|
|
371
|
+
)
|
|
372
|
+
SFA_chunk = USMChunkHeader.pack(
|
|
373
|
+
USMChunckHeaderType.SFA.value,
|
|
374
|
+
self.hca["HeaderSize"] + 0x18 + padding,
|
|
375
|
+
0,
|
|
376
|
+
0x18,
|
|
377
|
+
padding,
|
|
378
|
+
index,
|
|
379
|
+
0,
|
|
380
|
+
0,
|
|
381
|
+
0,
|
|
382
|
+
current_interval,
|
|
383
|
+
self.BASE_FRAMERATE,
|
|
384
|
+
0,
|
|
385
|
+
0,
|
|
386
|
+
)
|
|
387
|
+
SFA_chunk += self.get_header().ljust(self.hca["HeaderSize"] + padding, b"\x00")
|
|
388
|
+
res = []
|
|
389
|
+
res.append(SFA_chunk)
|
|
390
|
+
for i, frame in enumerate(self.get_frames(), start=1):
|
|
391
|
+
padding = (
|
|
392
|
+
0x20 - (self.hca["FrameSize"] % 0x20)
|
|
393
|
+
if self.hca["FrameSize"] % 0x20 != 0
|
|
394
|
+
else 0
|
|
395
|
+
)
|
|
396
|
+
SFA_chunk = USMChunkHeader.pack(
|
|
397
|
+
USMChunckHeaderType.SFA.value,
|
|
398
|
+
self.hca["FrameSize"] + 0x18 + padding,
|
|
399
|
+
0,
|
|
400
|
+
0x18,
|
|
401
|
+
padding,
|
|
402
|
+
index,
|
|
403
|
+
0,
|
|
404
|
+
0,
|
|
405
|
+
0,
|
|
406
|
+
current_interval,
|
|
407
|
+
self.BASE_FRAMERATE,
|
|
408
|
+
0,
|
|
409
|
+
0,
|
|
410
|
+
)
|
|
411
|
+
SFA_chunk += frame[1].ljust(self.hca["FrameSize"] + padding, b"\x00")
|
|
412
|
+
current_interval = round(i * self.CHUNK_INTERVAL)
|
|
413
|
+
res.append(SFA_chunk)
|
|
414
|
+
else:
|
|
415
|
+
SFA_chunk = USMChunkHeader.pack(
|
|
416
|
+
USMChunckHeaderType.SFA.value,
|
|
417
|
+
0x38,
|
|
418
|
+
0,
|
|
419
|
+
0x18,
|
|
420
|
+
0,
|
|
421
|
+
index,
|
|
422
|
+
0,
|
|
423
|
+
0,
|
|
424
|
+
2,
|
|
425
|
+
0,
|
|
426
|
+
30,
|
|
427
|
+
0,
|
|
428
|
+
0,
|
|
429
|
+
)
|
|
430
|
+
SFA_chunk += b"#CONTENTS END ===============\x00"
|
|
431
|
+
res[-1] += SFA_chunk
|
|
432
|
+
|
|
433
|
+
return res
|
|
434
|
+
|
|
435
|
+
def get_metadata(self):
|
|
436
|
+
payload = [dict(hca_header=(UTFTypeValues.bytes, self.get_header()))]
|
|
437
|
+
p = UTFBuilder(payload, table_name="AUDIO_HEADER")
|
|
438
|
+
p.strings = b"<NULL>\x00" + p.strings
|
|
439
|
+
return p.bytes()
|
|
440
|
+
|
|
441
|
+
def get_encoded(self):
|
|
442
|
+
"""Gets the encoded HCA audio data."""
|
|
443
|
+
self.hcastream.seek(0)
|
|
444
|
+
res = self.hcastream.read()
|
|
445
|
+
self.hcastream.seek(0)
|
|
446
|
+
return res
|
|
447
|
+
|
|
448
|
+
def save(self, filepath: str):
|
|
449
|
+
"""Saves the decoded WAV audio to filepath"""
|
|
450
|
+
with open(filepath, "wb") as f:
|
|
451
|
+
f.write(self.decode())
|