Squashed 'python/fsb5/' content from commit 5acfaed

git-subtree-dir: python/fsb5
git-subtree-split: 5acfaed9b44167eeebbd5f0414745cc23a2104a7
This commit is contained in:
Haoyu Xu
2025-05-01 10:40:57 +08:00
commit 57e889fb45
13 changed files with 31707 additions and 0 deletions

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
# EditorConfig: http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.py]
indent_style = tab
indent_size = 4
quote_type = double
spaces_around_brackets = none
spaces_around_operators = true
trim_trailing_whitespace = true

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Samples for testing
samples/
out/

201
FSB5.bt Normal file
View File

@@ -0,0 +1,201 @@
//--------------------------------------
//--- 010 Editor Binary Template
//
// File: FSB5.bt
// Author: Simon Pinfold
// Purpose: Parses the FSB5 (v0 and v1) audio container.
//--------------------------------------
BitfieldDisablePadding();
string OffsetCalcComment(int offset){
string s;
SPrintf(s, "Offset = %d from sampleData start", offset * 16);
return s;
}
string FrequencyLookupComment(int value){
switch (value){
case 1: return "8000Hz";
case 2: return "11000Hz";
case 3: return "11025Hz";
case 4: return "16000Hz";
case 5: return "22050Hz";
case 6: return "24000Hz";
case 7: return "32000Hz";
case 8: return "44100Hz";
case 9: return "48000Hz";
default: { Warning("Invalid value for frequency"); return "Unknown"; }
}
}
typedef enum<uint32> {
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
} MODE;
typedef enum<uint32> {
CHANNELS=1,
FREQUENCY=2,
LOOP=3,
XMASEEK=6,
DSPCOEFF=7,
XWMADATA=10,
VORBISDATA=11
} CHUNK_TYPE;
typedef struct {
char id[4];
if (id != "FSB5"){
Warning( "File is not FSB5. Template stopped." );
return -1;
}
int32 version;
int32 numSamples;
int32 sampleHeaderSize;
int32 nameTableSize;
int32 dataSize;
MODE mode;
byte zero[8];
byte hash[16];
byte dummy[8];
if (version == 0) {
uint32 unknown <format=hex, bgcolor=0x0000ff>;
}
} FSOUND_FSB_HEADER_FSB5;
typedef struct {
uint32 extraParams :1;
uint32 frequency :4 <comment=FrequencyLookupComment>;
uint32 twoChannels :1;
uint32 dataOffset :28 <comment=OffsetCalcComment>;
uint32 samples :30;
if (extraParams){ // has extra params
local int _next = 1;
while (_next){
struct {
uint32 next :1;
uint32 size :24;
CHUNK_TYPE type :7;
if (type == CHANNELS){
Assert(size == 1, "Channels chunk should be 1 byte");
byte channels;
} else if (type == FREQUENCY){
Assert(size == 4, "Frequency chunk should be 4 bytes");
uint32 frequency;
} else if (type == LOOP){
Assert(size == 8, "Frequency chunk should be 8 bytes");
struct {
uint32 loopstart;
uint32 loopend;
} loop;
} else if (type == XMASEEK){
byte xmaSeek[size];
} else if (type == DSPCOEFF){
byte dspCoefficient[size];
} else if (type == XWMADATA){
byte xwmaData[size];
} else if (type == VORBISDATA){
struct {
uint32 crc32;
local int _remain = size - 4;
while (_remain > 0){
struct {
uint32 offset;
if ( _remain > 4) uint32 granulePosition;
} packetData;
_remain -= 8;
}
//byte unknownData[size-4] <format=hex>;
} vorbis;
} else {
byte unknownData[size];
}
} chunk;
_next = chunk.next;
}
}
} FSOUND_FSB_SAMPLE_HEADER;
FSOUND_FSB_HEADER_FSB5 header <bgcolor=0xffbf00>;
struct {
local int i;
for (i = 0; i < header.numSamples; i++) {
FSOUND_FSB_SAMPLE_HEADER sampleHeader;
}
} sampleHeaders <bgcolor=0x00ffff>;
if (header.nameTableSize) {
local int nameTableStart = FTell();
struct {
local int i;
for (i = 0; i < header.numSamples; i++) {
uint32 nameStart;
}
for (i = 0; i < header.numSamples; i++) {
struct {
Assert(nameTableStart + nameTable.nameStart[i] == FTell(), "nameStart did not point to expected start of string");
string name;
} name;
}
} nameTable <bgcolor=0x33ff00>;
} else {
Printf("No name table\n");
}
byte pad[(sizeof(header) + header.sampleHeaderSize + header.nameTableSize) - FTell()];
struct {
local int dataStart = FTell();
local int dataEnd = dataStart + header.dataSize;
local int i, start, end;
for (i = 0; i < header.numSamples; i++) {
end = dataEnd;
if (i+1 < header.numSamples) end = dataStart + sampleHeaders.sampleHeader[i+1].dataOffset * 16;
start = dataStart + sampleHeaders.sampleHeader[i ].dataOffset * 16;
Assert(start == FTell(), "Wrong length calculated for sample data");
struct {
if (header.mode == VORBIS){
while (!FEof()){
struct {
ushort size;
if (!size)
return 0;
ubyte audio :1;
ubyte r :7;
ubyte data[size-1];
} packet;
}
} else
byte bytes[end - start] <bgcolor=cLtGray>;
} sample;
}
} sampleData;

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Simon Pinfold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# python-fsb5
Python library and tool to extract FSB5 (FMOD Sample Bank) files.
### Supported formats
- MPEG
- Vorbis (OGG)
- WAVE (PCM8, PCM16, PCM32)
Other formats can be identified but will be extracted as `.dat` files and may not play as the headers may be missing.
## Tool Usage
```
usage: extract.py [-h] [-o OUTPUT_DIRECTORY] [-p] [-q]
[fsb_file [fsb_file ...]]
Extract audio samples from FSB5 files
positional arguments:
fsb_file FSB5 container to extract audio from (defaults to
stdin)
optional arguments:
-h, --help show this help message and exit
-o OUTPUT_DIRECTORY, --output-directory OUTPUT_DIRECTORY
output directory to write extracted samples into
-q, --quiet suppress output of header and sample information
(samples that failed to decode will still be printed)
```
#### Resource files
Unity3D packs multiple FSB5 files each containing a single sample into it's `.resource` files.
python-fsb5 will automatically extract all samples if multiple FSB5s are found within one file.
Output files will be prefixed with the (0 based) index of their FSB container within the resource file e.g. `out/sounds-15-track1.wav` is the path for a WAVE sample named track1 which is contained within the 16th FSB file within sounds.resource.
#### Unnamed samples
FSB5 does not require samples to store a name. If samples are stored without a name they will use their index within the FSB e.g. `sounds-0000.mp3` is the first sample in sounds.fsb.
## Requirements
python-fsb5 should work with python3 from version 3.2 and up.
`libogg` and `libvorbis` are required to decode ogg samples. For linux simply install from your package manager. For windows ensure the dlls are avaliable (ie. in System32 or the directory you are running the script from). Known working dlls are avaliable as part of the [release](https://github.com/HearthSim/python-fsb5/releases/tag/b7bf605).
If ogg files are not required to be decoded then the libraries are not required.
## Library usage
```python
import fsb5
# read the file into a FSB5 object
with open('sample.fsb', 'rb') as f:
fsb = fsb5.FSB5(f.read())
print(fsb.header)
# get the extension of samples based off the sound format specified in the header
ext = fsb.get_sample_extension()
# iterate over samples
for sample in fsb.samples:
# print sample properties
print('''\t{sample.name}.{extension}:
Frequency: {sample.frequency}
Channels: {sample.channels}
Samples: {sample.samples}'''.format(sample=sample, extension=ext))
# rebuild the sample and save
with open('{0}.{1}'.format(sample.name, ext), 'wb') as f:
rebuilt_sample = fsb.rebuild_sample(sample)
f.write(rebuilt_sample)
```
#### Useful header properties
- `numSamples`: The number of samples contained in the file
- `mode`: The audio format of all samples. Can be one of:
* `fsb5.SoundFormat.NONE`
* `fsb5.SoundFormat.PCM8`
* `fsb5.SoundFormat.PCM16`
* `fsb5.SoundFormat.PCM24`
* `fsb5.SoundFormat.PCM32`
* `fsb5.SoundFormat.PCMFLOAT`
* `fsb5.SoundFormat.GCADPCM`
* `fsb5.SoundFormat.IMAADPCM`
* `fsb5.SoundFormat.VAG`
* `fsb5.SoundFormat.HEVAG`
* `fsb5.SoundFormat.XMA`
* `fsb5.SoundFormat.MPEG`
* `fsb5.SoundFormat.CELT`
* `fsb5.SoundFormat.AT9`
* `fsb5.SoundFormat.XWMA`
* `fsb5.SoundFormat.VORBIS`
#### Useful sample properties
- `name` : The name of the sample, or a 4 digit number if names are not provided.
- `frequency` : The sample rate of the audio
- `channels` : The number of channels of audio (either 1 or 2)
- `samples` : The number of samples in the audio
- `metadata` : A dictionary of `fsb5.MetadataChunkType` to tuple (sometimes namedtuple) or bytes.
All contents of sample.metadata is optional and often not provided. Several metadata types seem to override sample properties.
Supported `fsb5.MetadataChunkType`s are:
* `CHANNELS`: A 1-tuple containing the number of channels
* `FREQUENCY`: A 1-tuple containing the sample rate
* `LOOP`: A 2-tuple of the loop start and end
* `XMASEEK`: Raw bytes
* `DSPCOEFF`: Raw bytes
* `XWMADATA`: Raw bytes
* `VORBISDATA`: A named tuple with properties `crc32` (int) and `unknown` (bytes)
If a metadata chunk is unrecognized it will be included in the dictionary as an interger mapping to a bytes.
#### Rebuilding samples
Samples also have the `data` property.
This contains the raw, unprocessed audio data for that sample from the FSB file.
To reconstruct a playable version of the audio use `rebuild_sample` on the FSB5 object passing the sample desired to be rebuilt.
## License
python-fsb5 is licensed under the terms of the MIT license.
The full text of the license is available in the LICENSE file.

131
extract.py Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
import argparse
import sys
import os
import fsb5
class FSBExtractor:
description = 'Extract audio samples from FSB5 files'
def __init__(self):
self.parser = self.init_parser()
def init_parser(self):
parser = argparse.ArgumentParser(description=self.description)
parser.add_argument('fsb_file', nargs='*', type=str,
help='FSB5 container to extract audio from (defaults to stdin)'
)
parser.add_argument('-o', '--output-directory', default='out/',
help='output directory to write extracted samples into'
)
parser.add_argument('--verbose', action='store_true',
help='be more verbose during extraction'
)
return parser
def print(self, *args):
print(*args)
def debug(self, *args):
if self.args.verbose:
self.print(*args)
def error(self, *args):
print(*args, file=sys.stderr)
def write_to_file(self, filename_prefix, filename, extension, contents):
directory = self.args.output_directory
if not os.path.exists(directory):
os.makedirs(directory)
if filename_prefix:
path = os.path.join(directory, '{0}-{1}.{2}'.format(filename_prefix, filename, extension))
else:
path = os.path.join(directory, '{0}.{1}'.format(filename, extension))
with open(path, 'wb') as f:
written = f.write(contents)
return path
def load_fsb(self, data):
fsb = fsb5.load(data)
ext = fsb.get_sample_extension()
self.debug('\nHeader:')
self.debug('\tVersion: 5.%s' % (fsb.header.version))
self.debug('\tSample count: %i' % (fsb.header.numSamples))
self.debug('\tNamed samples: %s' % ('Yes' if fsb.header.nameTableSize else 'No'))
self.debug('\tSound format: %s' % (fsb.header.mode.name.capitalize()))
return fsb, ext
def read_samples(self, fsb_name, fsb, ext):
self.debug('Samples:')
for sample in fsb.samples:
self.debug('\t%s.%s' % (sample.name, ext))
self.debug('\tFrequency: %iHz' % (sample.frequency))
self.debug('\tChannels: %i' % (sample.channels))
self.debug('\tSamples %r' % (sample.samples))
if sample.metadata and self.args.verbose:
for meta_type, meta_value in sample.metadata.items():
if type(meta_type) is fsb5.MetadataChunkType:
contents = str(meta_value)
if len(contents) > 45:
contents = contents[:45] + '... )'
self.debug('\t%s: %s' % (meta_type.name, contents))
else:
self.debug('\t<unknown metadata type: %r>' % (meta_type))
sample_fakepath = '{0}:{1}.{2}'.format(fsb_name, sample.name, ext)
try:
yield sample_fakepath, sample.name, fsb.rebuild_sample(sample)
except ValueError as e:
self.error('FAILED to extract %r: %s' % (sample_fakepath, e))
def handle_file(self, f):
data = f.read()
fsb_name = os.path.splitext(os.path.basename(f.name))[0]
self.debug('Reading FSB5 container: %s' % (f.name))
is_resource = False
index = 0
while data:
fsb, ext = self.load_fsb(data)
data = data[fsb.raw_size:]
if not is_resource and data:
is_resource = True
sample_prefix = fsb_name
fakepath_prefix = fsb_name
if is_resource:
sample_prefix += '-%d' % (index)
fakepath_prefix += ':%d' % (index)
for sample_fakepath, sample_name, sample_data in self.read_samples(fakepath_prefix, fsb, ext):
outpath = self.write_to_file(sample_prefix, sample_name, ext, sample_data)
self.print('%r -> %r' % (sample_fakepath, outpath))
index += 1
def run(self, args):
self.args = self.parser.parse_args(args)
for fname in self.args.fsb_file:
with open(fname, 'rb') as f:
self.handle_file(f)
return 0
def main():
app = FSBExtractor()
exit(app.run(sys.argv[1:]))
if __name__ == '__main__':
main()

230
fsb5/__init__.py Normal file
View File

@@ -0,0 +1,230 @@
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)

11
fsb5/pcm.py Normal file
View File

@@ -0,0 +1,11 @@
import wave
from io import BytesIO
def rebuild(sample, width):
data = sample.data[:sample.samples * width]
ret = BytesIO()
with wave.open(ret, "wb") as wav:
wav.setparams((sample.channels, width, sample.frequency, 0, "NONE", "NONE"))
wav.writeframes(data)
return ret.getvalue()

76
fsb5/utils.py Normal file
View File

@@ -0,0 +1,76 @@
import os
import ctypes
import struct
class BinaryReader:
def __init__(self, buf, endian="<"):
self.buf = buf
self.endian = endian
self.seek(0, 2)
self.size = self.tell()
self.seek(0)
def read(self, *args):
return self.buf.read(*args)
def seek(self, *args):
return self.buf.seek(*args)
def tell(self):
return self.buf.tell()
def finished(self):
return self.tell() == self.size
def read_string(self, maxlen=0):
r = []
start = self.tell()
while maxlen == 0 or len(r) <= maxlen:
c = self.read(1)
if not c:
raise ValueError("Unterminated string starting at %d" % (start))
if c == b"\0":
break
r.append(c)
return b"".join(r)
def struct_calcsize(self, fmt):
return struct.calcsize(fmt)
def read_struct(self, fmt, endian=None):
fmt = (endian or self.endian) + fmt;
fmtlen = struct.calcsize(fmt)
data = self.read(fmtlen)
if len(data) != fmtlen:
raise ValueError("Not enough bytes left in buffer to read struct")
return struct.unpack(fmt, data)
def read_struct_into(self, dest, fmt, endian=None):
fields = self.read_struct(fmt, endian=endian)
fields = list(fields) + [None] * (len(dest._fields) - len(fields))
return dest._make(fields)
def read_type(self, type_fmt, endian=None):
r = self.read_struct(type_fmt, endian=endian)
if len(r) != 1:
raise ValueError("Format %r did not describe a single type" % (type_fmt))
return r[0]
class LibraryNotFoundException(OSError):
pass
def load_lib(*names):
for name in names:
try:
libname = ctypes.util.find_library(name)
if libname:
return ctypes.CDLL(libname)
else:
dll_path = os.path.join(os.getcwd(), "lib%s.dll" % (name))
return ctypes.CDLL(dll_path)
except OSError:
pass
raise LibraryNotFoundException("Could not load the library %r" % (names[0]))

379
fsb5/vorbis.py Normal file
View File

@@ -0,0 +1,379 @@
import ctypes
import ctypes.util
import os
from enum import IntEnum
from io import BytesIO
from . import *
from .utils import BinaryReader, load_lib
from .vorbis_headers import lookup as vorbis_header_lookup
vorbis = load_lib('vorbis')
ogg = load_lib('ogg')
class VorbisInfo(ctypes.Structure):
"""
https://xiph.org/vorbis/doc/libvorbis/vorbis_info.html
"""
_fields_ = [
('version', ctypes.c_int),
('channels', ctypes.c_int),
('rate', ctypes.c_long),
('bitrate_upper', ctypes.c_long),
('bitrate_nominal', ctypes.c_long),
('bitrate_lower', ctypes.c_long),
('bitrate_window', ctypes.c_long),
('codec_setup', ctypes.c_void_p),
]
def __init__(self):
super().__init__()
vorbis.vorbis_info_init(self)
def __del__(self):
vorbis.vorbis_info_clear(self)
class VorbisComment(ctypes.Structure):
"""
https://xiph.org/vorbis/doc/libvorbis/vorbis_info.html
"""
_fields_ = [
('user_comments', ctypes.POINTER(ctypes.c_char_p)),
('comment_lengths', ctypes.POINTER(ctypes.c_int)),
('comments', ctypes.c_int),
('vendor', ctypes.c_char_p)
]
def __init__(self):
super().__init__()
vorbis.vorbis_comment_init(self)
def __del__(self):
vorbis.vorbis_comment_clear(self)
class VorbisDSPState(ctypes.Structure):
"""
https://svn.xiph.org/trunk/vorbis/include/vorbis/codec.h
"""
_fields_ = [
('analysisp', ctypes.c_int),
('vi', ctypes.c_void_p),
('pcm', ctypes.POINTER(ctypes.POINTER(ctypes.c_float))),
('pcmret', ctypes.POINTER(ctypes.POINTER(ctypes.c_float))),
('pcm_storage', ctypes.c_int),
('pcm_current', ctypes.c_int),
('pcm_returned', ctypes.c_int),
('preextrapolate', ctypes.c_int),
('eofflag', ctypes.c_int),
('lW', ctypes.c_long),
('W', ctypes.c_long),
('nW', ctypes.c_long),
('centerW', ctypes.c_long),
('granulepos', ctypes.c_longlong),
('sequence', ctypes.c_longlong),
('glue_bits', ctypes.c_longlong),
('time_bits', ctypes.c_longlong),
('floor_bits', ctypes.c_longlong),
('res_bits', ctypes.c_longlong),
('backend_state', ctypes.c_void_p)
]
class OggStreamState(ctypes.Structure):
"""
https://xiph.org/ogg/doc/libogg/ogg_stream_state.html
"""
_fields_ = [
('body_data', ctypes.POINTER(ctypes.c_char)),
('body_storage', ctypes.c_long),
('body_fill', ctypes.c_long),
('body_returned', ctypes.c_long),
('lacing_vals', ctypes.POINTER(ctypes.c_int)),
('granule_vals', ctypes.POINTER(ctypes.c_longlong)),
('lacing_storage', ctypes.c_long),
('lacing_fill', ctypes.c_long),
('lacing_packet', ctypes.c_long),
('lacing_returned', ctypes.c_long),
('header', ctypes.c_char * 282),
('header_fill', ctypes.c_int),
('e_o_s', ctypes.c_int),
('b_o_s', ctypes.c_int),
('serialno', ctypes.c_long),
('pageno', ctypes.c_int),
('packetno', ctypes.c_longlong),
('granulepos', ctypes.c_longlong)
]
def __init__(self, serialno):
super().__init__()
ogg.ogg_stream_init(self, serialno)
def __del__(self):
ogg.ogg_stream_clear(self)
class OggPacket(ctypes.Structure):
"""
https://xiph.org/ogg/doc/libogg/ogg_packet.html
"""
_fields_ = [
('packet', ctypes.POINTER(ctypes.c_char)),
('bytes', ctypes.c_long),
('b_o_s', ctypes.c_long),
('e_o_s', ctypes.c_long),
('granulepos', ctypes.c_longlong),
('packetno', ctypes.c_longlong)
]
class OggpackBuffer(ctypes.Structure):
"""
https://xiph.org/ogg/doc/libogg/oggpack_buffer.html
"""
_fields_ = [
('endbyte', ctypes.c_long),
('endbit', ctypes.c_int),
('buffer', ctypes.POINTER(ctypes.c_char)),
('ptr', ctypes.POINTER(ctypes.c_char)),
('storage', ctypes.c_long)
]
def __init__(self):
super().__init__()
ogg.oggpack_writeinit(self)
def __del__(self):
ogg.oggpack_writeclear(self)
class OggPage(ctypes.Structure):
"""
https://xiph.org/ogg/doc/libogg/oggpack_buffer.html
"""
_fields_ = [
('header', ctypes.POINTER(ctypes.c_char)),
('header_len', ctypes.c_long),
('body', ctypes.POINTER(ctypes.c_char)),
('body_len', ctypes.c_long)
]
def errcheck(result, func, arguments):
if result != 0:
raise OSError('Call to %s(%s) returned %d (error)' % (func.__name__, ', '.join(str(x) for x in arguments), result))
return result == 0
######## libvorbis functions ########
vorbis.vorbis_info_init.argtypes = [ctypes.POINTER(VorbisInfo)]
vorbis.vorbis_info_init.restype = None
vorbis.vorbis_info_clear.argtypes = [ctypes.POINTER(VorbisInfo)]
vorbis.vorbis_info_clear.restype = None
vorbis.vorbis_comment_init.argtypes = [ctypes.POINTER(VorbisComment)]
vorbis.vorbis_comment_init.restype = None
vorbis.vorbis_comment_clear.argtypes = [ctypes.POINTER(VorbisComment)]
vorbis.vorbis_comment_clear.restype = None
vorbis.vorbis_analysis_init.argtypes = [ctypes.POINTER(VorbisDSPState), ctypes.POINTER(VorbisInfo)]
vorbis.vorbis_analysis_init.errcheck = errcheck
vorbis.vorbis_analysis_headerout.argtypes = [
ctypes.POINTER(VorbisDSPState),
ctypes.POINTER(VorbisComment),
ctypes.POINTER(OggPacket),
ctypes.POINTER(OggPacket),
ctypes.POINTER(OggPacket)
]
vorbis.vorbis_analysis_headerout.errcheck = errcheck
vorbis.vorbis_dsp_clear.argtypes = [ctypes.POINTER(VorbisDSPState)]
vorbis.vorbis_dsp_clear.restype = None
vorbis.vorbis_commentheader_out.argtypes = [ctypes.POINTER(VorbisComment), ctypes.POINTER(OggPacket)]
vorbis.vorbis_commentheader_out.errcheck = errcheck
vorbis.vorbis_synthesis_headerin.argtypes = [
ctypes.POINTER(VorbisInfo),
ctypes.POINTER(VorbisComment),
ctypes.POINTER(OggPacket)
]
vorbis.vorbis_synthesis_headerin.errcheck = errcheck
def vorbis_packet_blocksize_errcheck(result, func, arguments):
if result < 0:
errcheck(result, func, arguments)
return result
vorbis.vorbis_packet_blocksize.argtypes = [ctypes.POINTER(VorbisInfo), ctypes.POINTER(OggPacket)]
vorbis.vorbis_packet_blocksize.errcheck = vorbis_packet_blocksize_errcheck
######## libogg functions ########
ogg.ogg_stream_init.argtypes = [ctypes.POINTER(OggStreamState), ctypes.c_int]
ogg.ogg_stream_init.errcheck = errcheck
ogg.ogg_stream_clear.argtypes = [ctypes.POINTER(OggStreamState)]
ogg.ogg_stream_clear.restype = ctypes.c_int
ogg.oggpack_writeinit.argtypes = [ctypes.POINTER(OggpackBuffer)]
ogg.oggpack_writeinit.restype = None
ogg.oggpack_write.argtypes = [ctypes.POINTER(OggpackBuffer), ctypes.c_ulong, ctypes.c_int]
ogg.oggpack_write.restype = None
ogg.oggpack_writeclear.argtypes = [ctypes.POINTER(OggpackBuffer)]
ogg.oggpack_writeclear.restype = None
ogg.oggpack_bytes.argtypes = [ctypes.POINTER(OggpackBuffer)]
ogg.oggpack_bytes.restype = ctypes.c_int
ogg.oggpack_writeclear.argtypes = [ctypes.POINTER(OggpackBuffer)]
ogg.oggpack_writeclear.restype = None
ogg.ogg_packet_clear.argtypes = [ctypes.POINTER(OggPacket)]
ogg.ogg_packet_clear.restype = None
ogg.ogg_stream_packetin.argtypes = [ctypes.POINTER(OggStreamState), ctypes.POINTER(OggPacket)]
ogg.ogg_stream_packetin.errcheck = errcheck
ogg.ogg_stream_pageout.argtypes = [ctypes.POINTER(OggStreamState), ctypes.POINTER(OggPage)]
ogg.ogg_stream_pageout.restype = ctypes.c_int
ogg.ogg_stream_flush.argtypes = [ctypes.POINTER(OggStreamState), ctypes.POINTER(OggPage)]
ogg.ogg_stream_flush.restype = ctypes.c_int
if hasattr(ogg, 'oggpack_writecheck'):
ogg.oggpack_writecheck.argtypes = [ctypes.POINTER(OggpackBuffer)]
ogg.oggpack_writecheck.errcheck = errcheck
def rebuild(sample):
if MetadataChunkType.VORBISDATA not in sample.metadata:
raise ValueError('Expected sample header to contain a VORBISDATA chunk but none was found')
crc32 = sample.metadata[MetadataChunkType.VORBISDATA].crc32
try:
setup_packet_buff = vorbis_header_lookup[crc32]
except KeyError as e:
raise ValueError('Could not find header info for crc32=%d' % crc32) from e
info = VorbisInfo()
comment = VorbisComment()
state = OggStreamState(1)
outbuf = BytesIO()
id_header = rebuild_id_header(sample.channels, sample.frequency, 0x100, 0x800)
comment_header = rebuild_comment_header()
setup_header = rebuild_setup_header(setup_packet_buff)
vorbis.vorbis_synthesis_headerin(info, comment, id_header)
vorbis.vorbis_synthesis_headerin(info, comment, comment_header)
vorbis.vorbis_synthesis_headerin(info, comment, setup_header)
ogg.ogg_stream_packetin(state, id_header)
write_packets(state, outbuf)
ogg.ogg_stream_packetin(state, comment_header)
write_packets(state, outbuf)
ogg.ogg_stream_packetin(state, setup_header)
write_packets(state, outbuf)
write_packets(state, outbuf, func=ogg.ogg_stream_flush)
packetno = setup_header.packetno
granulepos = 0
prev_blocksize = 0
inbuf = BinaryReader(BytesIO(sample.data))
packet_size = inbuf.read_type('H')
while packet_size:
packetno += 1
packet = OggPacket()
buf = ctypes.create_string_buffer(inbuf.read(packet_size), packet_size)
packet.packet = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))
packet.bytes = packet_size
packet.packetno = packetno
try:
packet_size = inbuf.read_type('H')
except ValueError:
packet_size = 0
packet.e_o_s = 1 if not packet_size else 0
blocksize = vorbis.vorbis_packet_blocksize(info, packet)
assert blocksize
granulepos = int(granulepos + (blocksize + prev_blocksize) / 4) if prev_blocksize else 0
packet.granulepos = granulepos
prev_blocksize = blocksize
ogg.ogg_stream_packetin(state, packet)
write_packets(state, outbuf)
return outbuf.getbuffer()
def write_packets(state, buf, func=ogg.ogg_stream_pageout):
page = OggPage()
while func(state, page):
buf.write(bytes(page.header[:page.header_len]))
buf.write(bytes(page.body[:page.body_len]))
def rebuild_id_header(channels, frequency, blocksize_short, blocksize_long):
packet = OggPacket()
buf = OggpackBuffer()
ogg.oggpack_write(buf, 0x01, 8)
for c in 'vorbis':
ogg.oggpack_write(buf, ord(c), 8)
ogg.oggpack_write(buf, 0, 32)
ogg.oggpack_write(buf, channels, 8)
ogg.oggpack_write(buf, frequency, 32)
ogg.oggpack_write(buf, 0, 32)
ogg.oggpack_write(buf, 0, 32)
ogg.oggpack_write(buf, 0, 32)
ogg.oggpack_write(buf, len(bin(blocksize_short)) - 3, 4)
ogg.oggpack_write(buf, len(bin(blocksize_long)) - 3, 4)
ogg.oggpack_write(buf, 1, 1)
if hasattr(ogg, 'oggpack_writecheck'):
ogg.oggpack_writecheck(buf)
packet.bytes = ogg.oggpack_bytes(buf)
buf = ctypes.create_string_buffer(bytes(buf.buffer[:packet.bytes]), packet.bytes)
packet.packet = ctypes.cast(ctypes.pointer(buf), ctypes.POINTER(ctypes.c_char))
packet.b_o_s = 1
packet.e_o_s = 0
packet.granulepos = 0
packet.packetno = 0
return packet
def rebuild_comment_header():
packet = OggPacket()
ogg.ogg_packet_clear(packet)
comment = VorbisComment()
vorbis.vorbis_commentheader_out(comment, packet)
return packet
def rebuild_setup_header(setup_packet_buff):
packet = OggPacket()
packet.packet = ctypes.cast(ctypes.pointer(ctypes.create_string_buffer(setup_packet_buff, len(setup_packet_buff))), ctypes.POINTER(ctypes.c_char))
packet.bytes = len(setup_packet_buff)
packet.b_o_s = 0
packet.e_o_s = 0
packet.granulepos = 0
packet.packetno = 2
return packet

30321
fsb5/vorbis_headers.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
import argparse
import pefile
import struct
from pprint import pprint
def main():
pp = argparse.ArgumentParser(description='Dump FSB5 vorbis headers from a 32-bit Windows executable')
pp.add_argument('file', help='path to executable file')
args = pp.parse_args()
pe = pefile.PE(args.file)
lookup = {}
base_addr = pe.OPTIONAL_HEADER.ImageBase
# reimplementation of get_memory_mapped_image because pefile did not survive
# the port to python 3 fully in tact...
mapped_data = pe.__data__[:]
for section in pe.sections:
# Miscellaneous integrity tests.
# Some packer will set these to bogus values to make tools go nuts.
if section.Misc_VirtualSize == 0 or section.SizeOfRawData == 0:
continue
if section.SizeOfRawData > len(pe.__data__):
continue
if pe.adjust_FileAlignment( section.PointerToRawData,
pe.OPTIONAL_HEADER.FileAlignment ) > len(pe.__data__):
continue
VirtualAddress_adj = pe.adjust_SectionAlignment( section.VirtualAddress,
pe.OPTIONAL_HEADER.SectionAlignment, pe.OPTIONAL_HEADER.FileAlignment )
padding_length = VirtualAddress_adj - len(mapped_data)
if padding_length>0:
mapped_data += b'\0'*padding_length
elif padding_length<0:
mapped_data = mapped_data[:padding_length]
mapped_data += section.get_data()
vas = []
va = 0
while True:
va = mapped_data.find(b'\x05vorbis', va)
if va > 0:
vas.append(base_addr + va)
va += 7
else:
break
refs = []
for va in vas:
word = struct.pack('<I', va)
ref = 0
while True:
ref = mapped_data.find(word, ref)
if ref > 0:
refs.append(base_addr + ref)
ref += 4
else:
break
refs.sort()
table_addr = 0
for ref in refs:
if (ref - 24 not in refs and ref - 12 not in refs) and (ref + 24 in refs or ref + 36 in refs):
table_addr = ref
break
if table_addr == 0:
raise
ea = table_addr
while True:
ea_ofs = ea - base_addr
buf1, len1, crc, buf2, ofs, len2 = struct.unpack('<IIIIII', mapped_data[ea_ofs:ea_ofs+0x18])
if crc == 0 or len1 == 0 or buf1 == 0 or len1 > 8192 or len2 > 8192:
break
hdr = bytearray(len1)
base_buf = buf1
if buf2 != 0:
base_buf = buf2
for j in range(len1):
hdr[j] = mapped_data[base_buf + j - base_addr]
if buf2 != 0:
for j in range(len2):
hdr[ofs+j] = mapped_data[buf1 + j - base_addr]
lookup[crc] = bytes(hdr)
ea += 24
print('lookup = ', end='')
pprint(lookup)
if __name__ == '__main__':
main()

31
setup.py Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import fsb5
from setuptools import setup, find_packages
CLASSIFIERS = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Multimedia :: Sound/Audio :: Conversion",
]
setup(
name="fsb5",
version=fsb5.__version__,
author=fsb5.__author__,
author_email=fsb5.__email__,
description="Library and to extract audio from FSB5 (FMOD Sample Bank) files",
download_url="https://github.com/HearthSim/python-fsb5/tarball/master",
license="MIT",
url="https://github.com/HearthSim/python-fsb5",
classifiers=CLASSIFIERS,
packages=find_packages(),
)