231 lines
5.3 KiB
Python
231 lines
5.3 KiB
Python
from collections import namedtuple
|
|
from enum import IntEnum
|
|
from io import BytesIO
|
|
|
|
from .utils import BinaryReader
|
|
|
|
|
|
__version__ = "1.0"
|
|
__author__ = "Simon Pinfold"
|
|
__email__ = "simon@uint8.me"
|
|
|
|
|
|
class SoundFormat(IntEnum):
|
|
NONE = 0
|
|
PCM8 = 1
|
|
PCM16 = 2
|
|
PCM24 = 3
|
|
PCM32 = 4
|
|
PCMFLOAT = 5
|
|
GCADPCM = 6
|
|
IMAADPCM = 7
|
|
VAG = 8
|
|
HEVAG = 9
|
|
XMA = 10
|
|
MPEG = 11
|
|
CELT = 12
|
|
AT9 = 13
|
|
XWMA = 14
|
|
VORBIS = 15
|
|
|
|
@property
|
|
def file_extension(self):
|
|
if self == SoundFormat.MPEG:
|
|
return "mp3"
|
|
elif self == SoundFormat.VORBIS:
|
|
return "ogg"
|
|
elif self.is_pcm:
|
|
return "wav"
|
|
return "bin"
|
|
|
|
@property
|
|
def is_pcm(self):
|
|
return self in (SoundFormat.PCM8, SoundFormat.PCM16, SoundFormat.PCM32)
|
|
|
|
|
|
FSB5Header = namedtuple("FSB5Header", [
|
|
"id",
|
|
"version",
|
|
"numSamples",
|
|
"sampleHeadersSize",
|
|
"nameTableSize",
|
|
"dataSize",
|
|
"mode",
|
|
|
|
"zero",
|
|
"hash",
|
|
"dummy",
|
|
|
|
"unknown",
|
|
|
|
"size"
|
|
])
|
|
|
|
Sample = namedtuple("Sample", [
|
|
"name",
|
|
"frequency",
|
|
"channels",
|
|
"dataOffset",
|
|
"samples",
|
|
|
|
"metadata",
|
|
|
|
"data"
|
|
])
|
|
|
|
frequency_values = {
|
|
1: 8000,
|
|
2: 11000,
|
|
3: 11025,
|
|
4: 16000,
|
|
5: 22050,
|
|
6: 24000,
|
|
7: 32000,
|
|
8: 44100,
|
|
9: 48000
|
|
}
|
|
|
|
class MetadataChunkType(IntEnum):
|
|
CHANNELS = 1
|
|
FREQUENCY = 2
|
|
LOOP = 3
|
|
XMASEEK = 6
|
|
DSPCOEFF = 7
|
|
XWMADATA = 10
|
|
VORBISDATA = 11
|
|
|
|
chunk_data_format = {
|
|
MetadataChunkType.CHANNELS : "B",
|
|
MetadataChunkType.FREQUENCY: "I",
|
|
MetadataChunkType.LOOP: "II"
|
|
}
|
|
|
|
VorbisData = namedtuple("VorbisData", ["crc32", "unknown"])
|
|
|
|
|
|
def bits(val, start, len):
|
|
stop = start + len
|
|
r = val & ((1<<stop)-1)
|
|
return r >> start
|
|
|
|
|
|
class FSB5:
|
|
def __init__(self, data):
|
|
buf = BinaryReader(BytesIO(data), endian="<")
|
|
|
|
magic = buf.read(4)
|
|
if magic != b"FSB5":
|
|
raise ValueError("Expected magic header 'FSB5' but got %r" % (magic))
|
|
|
|
buf.seek(0)
|
|
self.header = buf.read_struct_into(FSB5Header, "4s I I I I I I 8s 16s 8s")
|
|
if self.header.version == 0:
|
|
self.header = self.header._replace(unknown=buf.read_type("I"))
|
|
self.header = self.header._replace(mode=SoundFormat(self.header.mode), size=buf.tell())
|
|
|
|
self.raw_size = self.header.size + self.header.sampleHeadersSize + self.header.nameTableSize + self.header.dataSize
|
|
|
|
self.samples = []
|
|
for i in range(self.header.numSamples):
|
|
raw = buf.read_type("Q")
|
|
next_chunk = bits(raw, 0, 1)
|
|
frequency = bits(raw, 1, 4)
|
|
channels = bits(raw, 1+4, 1) + 1
|
|
dataOffset = bits(raw, 1+4+1, 28) * 16
|
|
samples = bits(raw, 1+4+1+28, 30)
|
|
|
|
chunks = {}
|
|
while next_chunk:
|
|
raw = buf.read_type("I")
|
|
next_chunk = bits(raw, 0, 1)
|
|
chunk_size = bits(raw, 1, 24)
|
|
chunk_type = bits(raw, 1+24, 7)
|
|
|
|
try:
|
|
chunk_type = MetadataChunkType(chunk_type)
|
|
except ValueError:
|
|
pass
|
|
|
|
if chunk_type == MetadataChunkType.VORBISDATA:
|
|
chunk_data = VorbisData(
|
|
crc32 = buf.read_type("I"),
|
|
unknown = buf.read(chunk_size-4)
|
|
)
|
|
elif chunk_type in chunk_data_format:
|
|
fmt = chunk_data_format[chunk_type]
|
|
if buf.struct_calcsize(fmt) != chunk_size:
|
|
err = "Expected chunk %s of size %d, SampleHeader specified %d" % (
|
|
chunk_type, buf.struct_calcsize(fmt), chunk_size
|
|
)
|
|
raise ValueError(err)
|
|
chunk_data = buf.read_struct(fmt)
|
|
else:
|
|
chunk_data = buf.read(chunk_size)
|
|
|
|
chunks[chunk_type] = chunk_data
|
|
|
|
if MetadataChunkType.FREQUENCY in chunks:
|
|
frequency = chunks[MetadataChunkType.FREQUENCY][0]
|
|
elif frequency in frequency_values:
|
|
frequency = frequency_values[frequency]
|
|
else:
|
|
raise ValueError("Frequency value %d is not valid and no FREQUENCY metadata chunk was provided")
|
|
|
|
self.samples.append(Sample(
|
|
name="%04d" % (i),
|
|
frequency=frequency,
|
|
channels=channels,
|
|
dataOffset=dataOffset,
|
|
samples=samples,
|
|
metadata=chunks,
|
|
data=None
|
|
))
|
|
|
|
if self.header.nameTableSize:
|
|
nametable_start = buf.tell()
|
|
|
|
samplename_offsets = []
|
|
for i in range(self.header.numSamples):
|
|
samplename_offsets.append(buf.read_type("I"))
|
|
|
|
for i in range(self.header.numSamples):
|
|
buf.seek(nametable_start + samplename_offsets[i])
|
|
name = buf.read_string(maxlen=self.header.nameTableSize)
|
|
self.samples[i] = self.samples[i]._replace(name=name.decode("utf-8"))
|
|
|
|
buf.seek(self.header.size + self.header.sampleHeadersSize + self.header.nameTableSize)
|
|
for i in range(self.header.numSamples):
|
|
data_start = self.samples[i].dataOffset
|
|
data_end = data_start + self.header.dataSize
|
|
if i < self.header.numSamples-1:
|
|
data_end = self.samples[i+1].dataOffset
|
|
self.samples[i] = self.samples[i]._replace(data=buf.read(data_end - data_start))
|
|
|
|
def rebuild_sample(self, sample):
|
|
if sample not in self.samples:
|
|
raise ValueError("Sample to decode did not originate from the FSB archive decoding it")
|
|
if self.header.mode == SoundFormat.MPEG:
|
|
return sample.data
|
|
elif self.header.mode == SoundFormat.VORBIS:
|
|
# import here as vorbis.py requires native libraries
|
|
from . import vorbis
|
|
return vorbis.rebuild(sample)
|
|
elif self.header.mode.is_pcm:
|
|
from .pcm import rebuild
|
|
if self.header.mode == SoundFormat.PCM8:
|
|
width = 1
|
|
elif self.header.mode == SoundFormat.PCM16:
|
|
width = 2
|
|
else:
|
|
width = 4
|
|
return rebuild(sample, width)
|
|
|
|
raise NotImplementedError("Decoding samples of type %s is not supported" % (self.header.mode))
|
|
|
|
def get_sample_extension(self):
|
|
return self.header.mode.file_extension
|
|
|
|
|
|
def load(data):
|
|
return FSB5(data)
|