Проблема с преобразованием ADPCM в wav в Python

Я пытаюсь извлечь аудиофайлы из файлов .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, но все равно не могу решить проблему, как это исправить?

«Слишком быстро, высоко, звучит механически»: это проблема с частотой дискретизации (частотой). Кстати, пробовали ли вы использовать уже доступный инструмент для чтения/преобразования исходного файла? Winamp, MP3-кодер Lame, Soundforge, Wavelab, Audacity...

madgangmixers 03.05.2024 11:07

возможно, вы могли бы задать вопрос на аналогичном портале Обмен стеками обработки сигналов, потому что есть также вопросы об обработке звука.

furas 03.05.2024 11:17
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
1
2
62
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Я решил эту проблему. Оказывается, решение чрезвычайно простое.

Проверив файлы .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 следующий, все числа имеют прямой порядок байтов:

  • 0 б"РИФФ"
  • 1 длина файла + 4, в UInt32
  • 2 б"ВОЛНАfmt"

ADPCM использует заголовок длиной 60 байт, а PCM использует заголовок длиной 44 байта. Длина файла равна просто длине данных плюс длина заголовка. Я понятия не имею, почему существует бит плюс 4.

Теперь эти форматы заголовков расходятся, для ADPCM следующие 6 байт всегда одинаковы:

  • 3 б"\x14\x00\x00\x00\x11\x00"

Для PCM следующие 4 байта всегда одинаковы:

  • 3 б"\x10\x00\x00\x00\x01\x00"

Я понятия не имею, что означают эти байты, но первые 4 байта, похоже, связаны с длиной заголовка.

Затем они сходятся для следующих двух фрагментов:

  • 4 количество каналов UInt16
  • 5-частотный UInt32

Затем они снова расходятся.

Следующие 4 байта для WAV-заголовка PCM — это скорость передачи данных. Я обнаружил, что она равна:

  • 6 частота * размер/выборки UInt64

Я понятия не имею, что означают эти 4 байта для ADPCM, я рассчитал числа, и они просто не равны числам, которые я получил в этой позиции, но по какой-то причине, судя по моим тестам с шестнадцатеричным редактором, они, похоже, не влияют на успешность воспроизведения звука, поэтому я просто использую b"\x00\x00\x00\x00", чтобы заполнить его.

Следующий фрагмент, очевидно, связан с количеством каналов для PCM:

  • 7 каналов * 2 UInt16

Для АДПКМ:

  • 7 каналов * 36 UInt16

Теперь последние фрагменты, для PCM последние два фрагмента:

  • 8 б"\x10\x00данные"
  • 9 длина фактических данных UInt32

Для ADPCM это:

  • 8 b"\x04\x00\x02\x00\x00\x00факт\x04\x00\x00\x00"
  • 9 количество выборок UInt32
  • 10 б «данные»
  • 11 длина фактических данных UInt32

Вот код, я удалил импорт 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)

Другие вопросы по теме