Я пытаюсь извлечь аудиофайлы из файлов .fsb, используемых в Dragon Age: Origins. FSB означает «Банк образцов FMOD», и в игре используется FSB4. Поскольку это проприетарный формат, Google практически бесполезен в поиске соответствующей информации о нем, но мне удалось найти эту программу и этот репозиторий.
Глядя на файлы в шестнадцатеричном редакторе, мне было легко догадаться о структуре формата: она начинается с 48-байтового заголовка файла, который начинается с 4-байтовой подписи файла «FSB4», затем 4-байтового номера аудиофайлы, содержащиеся в архиве, затем 4-байтовый размер массива заголовков для отдельных файлов, 4-байтовый размер фактических данных файла, а затем еще что-то.
Дальше начинается массив заголовков для файлов, каждая запись почти всегда 80 байт, содержит имя файла, количество кадров в файле, длину данных файла и еще что-то.
Затем за массивом заголовков следуют 16 нулевых байтов, после чего фактические данные сохраняются последовательно голова к хвосту в том порядке, в котором они перечислены.
Были и другие вещи, которые я не смог расшифровать, и они были заполнены кодом из вышеупомянутого репозитория и протестированы с помощью программы с графическим интерфейсом.
С помощью реверс-инжиниринга я написал 100% рабочую программу для извлечения аудиофайлов из файлов .fsb, она работает, но для некоторых файлов извлеченный звук искажается.
Файл .fsb, используемый игрой, хранит аудио в трех форматах: MPEG, PCM и ADPCM. Я знаю, что файлы MPEG — это файлы .mp3, и они хранятся с полными заголовками, поэтому я просто сохраняю фрагменты (self.data[start:end]) напрямую, а файлы PCM — это просто файлы .wav без заголовков, поэтому я просто использую wave, чтобы добавить соответствующий заголовок. и запишите данные.
Проблема с ADPCM, я не знаю, как конвертировать ADPCM в PCM, и всем результатам я могу найти применение audioop.adpcm2lin(adpcm, 2, None). Все они ужасно устарели и в документации написано, что они устарели.
Я использую его в своем коде, и он вроде как работает, но извлекаемый звук слишком быстрый, высокий и звучит механически. Я не знаю, почему.
Вот код:
import audioop
import os
import struct
import wave
from pathlib import Path
class FSB4:
HEADERS = (
"signature",
"file_count",
"header_size",
"data_size",
"version",
"flags",
"padding",
"hash"
)
ENTRY_HEADERS = (
"frames",
"data_size",
"loop_start",
"loop_end",
"mode",
"frequency",
"pan",
"defpri",
"min_distance",
"channels",
"max_distance",
"var_frequency",
"var_vol",
"var_pan"
)
def __init__(self, file):
self.data = Path(file).read_bytes()
self.parse_header()
self.parse_entries()
def parse_header(self):
headers = self.data[:48]
self.chunks = [headers]
self.headers = dict(
zip(
self.HEADERS,
struct.unpack("<4s5I8s16s", headers)
)
)
def parse_entry(self, data):
chunks = struct.unpack("<H30s6I4H2f2I", data)
self.chunks.append(data)
flags = chunks[6]
entry = dict(zip(
self.ENTRY_HEADERS,
chunks[2:]
))
entry["format"] = (
"MPEG" if flags & 512 else (
"PCM" if flags & 16 else "ADPCM"
)
)
return (
chunks[1].strip(b"\x00").decode(),
entry
)
def parse_entries(self):
count = self.headers["file_count"]
offset = self.headers["header_size"] + 48
self.entries = {}
self.offsets = {}
for i in range(48, 48 + count * 80, 80):
name, entry = self.parse_entry(self.data[i:i+80])
self.entries[name] = entry
length = entry["data_size"]
self.offsets[name] = (offset, offset + length)
offset += length
self.chunks.append(self.data[i+80:i+96])
def extract_mp3(self, file, folder):
filename = file.rsplit(".", 1)[0] + ".mp3"
with open(os.path.join(folder, filename), "wb") as f:
start, end = self.offsets[file]
f.write(self.data[start:end])
def extract(self, file, folder):
entry = self.entries[file]
audio_format = entry["format"]
if audio_format == "MPEG":
self.extract_mp3(file, folder)
else:
path = os.path.join(folder, file.rsplit(".", 1)[0] + ".wav")
start, end = self.offsets[file]
pcm = self.data[start:end]
channels = entry["channels"]
if audio_format == "ADPCM":
pcm, _ = audioop.adpcm2lin(pcm, 2, None)
with wave.open(path, "wb") as wav:
frequency = entry["frequency"]
frames = entry["frames"]
wav.setparams((channels, 2, frequency, frames, 'NONE', 'NONE'))
wav.writeframes(pcm)
def extract_all(self, folder):
for file in self.entries:
self.extract(file, folder)
И файл для тестирования.
Я уже могу упаковать звуковые файлы в файл FSB4, но все равно не могу решить проблему, как это исправить?
возможно, вы могли бы задать вопрос на аналогичном портале Обмен стеками обработки сигналов, потому что есть также вопросы об обработке звука.






Я решил эту проблему. Оказывается, решение чрезвычайно простое.
Проверив файлы .wav, извлеченные с помощью wav.setparams, на наличие файлов PCM и файлы ADPCM, извлеченные с помощью экстрактора FSB Aezay, я определил, что правильное решение — просто добавить соответствующий заголовок в поток необработанных данных, поскольку я обнаружил, что все рабочие решения просто добавьте заголовок перед данными.
Экстрактор FSB просто добавляет заголовок для WAV-файлов ADPCM, но по какой-то причине audioop.adpcm2lin меняет поток данных и вызывает проблемы.
В ходе тестирования я обнаружил, что wav.setparams использует 44-байтовый заголовок для файлов PCM, а экстрактор FSB использует 60-байтовый заголовок для файлов ADPCM. Они начинаются с похожих фрагментов, но в конечном итоге расходятся. Обе спецификации отличаются от всех спецификаций заголовков .wav, которые я смог найти, однако я нашел около 5 из них, все они неправильные, но некоторые близки к 44-байтовому формату заголовка, используемому библиотекой wave.
Я потратил много времени, пытаясь согласовать форматы заголовков, которые нашел с помощью Google, и это было тщетно. Ценности просто не совпадают идеально. В конце концов я отказался от попыток следить за результатами поиска и вместо этого попытался самостоятельно перепроектировать форматы, а затем решил проблему.
Формат заголовка для wav следующий, все числа имеют прямой порядок байтов:
ADPCM использует заголовок длиной 60 байт, а PCM использует заголовок длиной 44 байта. Длина файла равна просто длине данных плюс длина заголовка. Я понятия не имею, почему существует бит плюс 4.
Теперь эти форматы заголовков расходятся, для ADPCM следующие 6 байт всегда одинаковы:
Для PCM следующие 4 байта всегда одинаковы:
Я понятия не имею, что означают эти байты, но первые 4 байта, похоже, связаны с длиной заголовка.
Затем они сходятся для следующих двух фрагментов:
Затем они снова расходятся.
Следующие 4 байта для WAV-заголовка PCM — это скорость передачи данных. Я обнаружил, что она равна:
Я понятия не имею, что означают эти 4 байта для ADPCM, я рассчитал числа, и они просто не равны числам, которые я получил в этой позиции, но по какой-то причине, судя по моим тестам с шестнадцатеричным редактором, они, похоже, не влияют на успешность воспроизведения звука, поэтому я просто использую b"\x00\x00\x00\x00", чтобы заполнить его.
Следующий фрагмент, очевидно, связан с количеством каналов для PCM:
Для АДПКМ:
Теперь последние фрагменты, для PCM последние два фрагмента:
Для ADPCM это:
Вот код, я удалил импорт wave и audioop, потому что мне не нужно их использовать. Я также исправил проблему, из-за которой если заголовок записи имел длину более 80 байт, это приводило к поломке кода.
import os
import struct
from pathlib import Path
class FSB4:
HEADERS = (
"signature",
"file_count",
"header_size",
"data_size",
"version",
"flags",
"padding",
"hash"
)
ENTRY_HEADERS = (
"frames",
"data_size",
"loop_start",
"loop_end",
"mode",
"frequency",
"pan",
"defpri",
"min_distance",
"channels",
"max_distance",
"var_frequency",
"var_vol",
"var_pan"
)
@staticmethod
def adpcm_wav_header(info):
return (
b"RIFF" +
(info["data_size"] + 64).to_bytes(4, "little") +
b"WAVEfmt " +
b"\x14\x00\x00\x00\x11\x00" +
(channels := info["channels"]).to_bytes(2, "little") +
info["frequency"].to_bytes(4, "little") +
b"\x00\x00\x00\x00" +
(channels * 36).to_bytes(2, "little") +
b"\x04\x00\x02\x00\x00\x00fact\x04\x00\x00\x00" +
info["frames"].to_bytes(4, "little") +
b"data" +
info["data_size"].to_bytes(4, "little")
)
@staticmethod
def wav_header(info):
return (
b"RIFF" +
((size := info["data_size"]) + 48).to_bytes(4, "little") +
b"WAVEfmt " +
b"\x10\x00\x00\x00\x01\x00" +
(channels := info["channels"]).to_bytes(2, "little") +
(freq := info["frequency"]).to_bytes(4, "little") +
(freq * size // info["frames"]).to_bytes(4, "little") +
(channels * 2).to_bytes(2, "little") +
b"\x10\x00data" +
info["data_size"].to_bytes(4, "little")
)
header_formatter = {
"PCM": wav_header,
"ADPCM": adpcm_wav_header
}
def __init__(self, file):
self.data = Path(file).read_bytes()
self.parse_header()
self.parse_entries()
def parse_header(self):
headers = self.data[:48]
self.chunks = [headers]
self.headers = dict(
zip(
self.HEADERS,
struct.unpack("<4s5I8s16s", headers)
)
)
def parse_entry(self, data):
chunks = struct.unpack("<30s6I4H2f2I", data)
flags = chunks[5]
entry = dict(zip(
self.ENTRY_HEADERS,
chunks[1:]
))
entry["format"] = (
"MPEG" if flags & 512 else (
"PCM" if flags & 16 else "ADPCM"
)
)
return (
chunks[0].strip(b"\x00").decode(),
entry,
)
def parse_entries(self):
count = self.headers["file_count"]
offset = self.headers["header_size"] + 48
self.entries = {}
self.offsets = {}
pos = 48
for _ in range(count):
length = int.from_bytes(self.data[pos:pos+2], "little")
chunk = self.data[pos:pos+length]
self.chunks.append(chunk)
name, entry = self.parse_entry(chunk[2:80])
if length > 80:
entry["extra"] = chunk[80:]
self.entries[name] = entry
size = entry["data_size"]
self.offsets[name] = (offset, offset + size)
pos += length
offset += size
self.chunks.append(self.data[pos+80:pos+96])
def extract(self, file, folder):
entry = self.entries[file]
audio_format = entry["format"]
ext = ".mp3" if audio_format == "MPEG" else ".wav"
filename = file.rsplit(".", 1)[0] + ext
with open(os.path.join(folder, filename), "wb") as f:
if formatter := self.header_formatter.get(audio_format):
f.write(formatter(entry))
start, end = self.offsets[file]
f.write(self.data[start:end])
def extract_all(self, folder):
for file in self.entries:
self.extract(file, folder)
«Слишком быстро, высоко, звучит механически»: это проблема с частотой дискретизации (частотой). Кстати, пробовали ли вы использовать уже доступный инструмент для чтения/преобразования исходного файла? Winamp, MP3-кодер Lame, Soundforge, Wavelab, Audacity...