diff --git a/app.py b/app.py index d18fbec..ce266f8 100644 --- a/app.py +++ b/app.py @@ -4,11 +4,12 @@ import json import math import extract import platform +import webbrowser from PyQt5 import uic from requests import get from PyQt5.QtGui import QTextCursor from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, QThread, QMetaType, Qt -from PyQt5.QtWidgets import QMessageBox, QMainWindow, QApplication, QFileDialog, QHeaderView, QAbstractItemView, QTreeWidgetItem +from PyQt5.QtWidgets import QMessageBox, QMainWindow, QApplication, QFileDialog, QHeaderView, QAbstractItemView, QTreeWidgetItem, QAction, QActionGroup QMetaType.type("QTextCursor") @@ -72,71 +73,114 @@ class AnimeWwise(QMainWindow): def __init__(self): super(AnimeWwise, self).__init__() uic.loadUi("gui.ui", self) - self.maps = self.getMaps() + self.maps = self.getJson("maps/index") + self.setWindowTitle(f'AnimeWwise | v{".".join(list(str(self.getJson("version")["version"])))}') self.folders = { "input": "", "output": "", "diff": "" } + self.format = "wav" + self.fileStructure = {"folders": {}, "files": []} self.setupActions() - sys.stdout = TextEditStream(self.console) + # sys.stdout = TextEditStream(self.console) self.extract = extract.WwiseExtract() - self.checkMapsUpdates() + self.checkUpdates() # utils self.selectFolder = lambda: QFileDialog.getExistingDirectory(self, "Select Folder") - def checkMapsUpdates(self): - print("Checking updates") + def checkUpdates(self): + print("Checking for updates...") try: - getVersion = lambda m: sum([int(e["version"].replace(".", "")) for e in m["maps"]]) - mapsVersion = getVersion(self.maps) - latestMaps = get("https://raw.githubusercontent.com/Escartem/AnimeWwise/master/maps/index.json") + currentVersion = self.getJson("version") + latestVersionReq = get("https://raw.githubusercontent.com/Escartem/AnimeWwise/master/version.json") - if latestMaps.status_code == 200: - latestVersion = getVersion(json.loads(latestMaps.text)) + if latestVersionReq.status_code == 200: + latestVersion = json.loads(latestMaps.text) - if mapsVersion < latestVersion: - print("Update found") - QMessageBox.information(None, "Info", "Newer version of the mappings are availble, please update the program", QMessageBox.Ok) + if currentVersion["version"] < latestVersion["version"]: + print("Update found !") + QMessageBox.information(None, "Info", "Newer version of the program is availble, please update.", QMessageBox.Ok) + elif currentVersion["mapsVersion"] < latestVersion["mapsVersion"]: + print("Update found !") + QMessageBox.information(None, "Info", "Newer version of the mappings are availble, please update the program.", QMessageBox.Ok) else: print("No updates") except: - print("Failed to check updates") + print("Failed to check updates :(") - def getMaps(self): - with open("maps/index.json", "r") as f: - maps = json.loads(f.read()) + def getJson(self, path): + with open(f"{path}.json", "r") as f: + data = json.loads(f.read()) f.close() - return maps + return data - def setFolder(self, elem, folder): + def setFolder(self, elem=None, folder=None): path = self.selectFolder() self.folders[folder] = path - elem.setText(path) + if elem: + elem.setText(path) def setupActions(self): self.changeInput.clicked.connect(lambda: self.setFolder(self.inputPath, "input")) self.changeAltInput.clicked.connect(lambda: self.setFolder(self.altInputPath, "diff")) - self.changeOutput.clicked.connect(lambda: self.setFolder(self.outputPath, "output")) - self.outputFormat.addItems(["wem (fastest)", "wav (fast)", "mp3 (slow)", "ogg (slow)"]) self.assetMap.addItems(["No map", *[f'{e["game"]} - v{e["version"]}' for e in self.maps["maps"]]]) - self.tabs.setTabEnabled(1, False) - self.tabs.setTabEnabled(2, False) + self.setExtractionState(False) + + self.updateTreeWidget(self.fileStructure) self.loadFilesButton.clicked.connect(lambda: self.loadFiles()) self.actionReset.triggered.connect(lambda: self.resetApp()) self.actionExit.triggered.connect(lambda: self.close()) - self.extractSelected.clicked.connect(lambda: self.extractItems(False)) - self.extractAll.clicked.connect(lambda: self.extractItems(True)) + self.actionExtract_Selected.triggered.connect(lambda: self.extractItems(False)) + self.actionExtract_All.triggered.connect(lambda: self.extractItems(True)) + + self.actionReport_a_bug.triggered.connect(lambda: self.openLink(0)) + self.actionSource_code.triggered.connect(lambda: self.openLink(1)) + self.actionDiscord.triggered.connect(lambda: self.openLink(2)) self.searchAsset.textChanged.connect(lambda: self.filterAsset()) + # output format + formats = ["wem (fastest)", "wav (fast)", "mp3 (slow)", "ogg (slow)"] + action_group = QActionGroup(self) + action_group.setExclusive(True) + + for index, item_name in enumerate(formats): + action = QAction(item_name, self) + action.setCheckable(True) + if index == 1: + action.setChecked(True) + self.menuOutput_format.addAction(action) + action_group.addAction(action) + + action_group.triggered.connect(self.updateFormat) + + def updateFormat(self, event): + text = event.text() + self.format = text.split(" ")[0] + + def openLink(self, id): + urls = [ + "https://github.com/Escartem/AnimeWwise/issues/new", + "https://github.com/Escartem/AnimeWwise", + "https://discord.gg/fzRdtVh" + ] + + webbrowser.open(urls[id]) + + def setExtractionState(self, state): + self.actionExtract_Selected.setEnabled(state) + self.actionExtract_All.setEnabled(state) + self.actionExpand_all.setEnabled(state) + self.actionCollapse_all.setEnabled(state) + # workers @pyqtSlot(list) def progressBarSlot(self, progress): @@ -152,23 +196,16 @@ class AnimeWwise(QMainWindow): if data["action"] == "load": self.fileStructure = data["content"] self.updateTreeWidget(self.fileStructure) - self.tabs.setTabEnabled(0, False) - self.tabs.setTabEnabled(1, True) - self.tabs.setTabEnabled(2, True) + self.setExtractionState(True) self.tabs.setCurrentIndex(1) print("Done !") if data["action"] == "error": QMessageBox.warning(None, "Warning", data["content"]["msg"], QMessageBox.Ok) state = data["content"]["state"] - if state == 1: - self.tabs.setTabEnabled(0, True) - elif state == 2: - self.tabs.setTabEnabled(1, True) - self.tabs.setTabEnabled(2, True) + if state == 2: + self.setExtractionState(True) if data["action"] == "extract": - self.tabs.setTabEnabled(1, True) - self.tabs.setTabEnabled(2, True) - self.tabs.setCurrentIndex(2) + self.setExtractionState(True) print("Finished extracting everything !") if platform.system() == "Windows": @@ -186,7 +223,6 @@ class AnimeWwise(QMainWindow): else: _map = None - self.tabs.setTabEnabled(0, False) self.resetTreeWidget() # why is all this required for threading damnit @@ -225,28 +261,36 @@ class AnimeWwise(QMainWindow): def resetTreeWidget(self): self.treeWidget.clear() - self.tabs.setTabEnabled(1, False) + self.fileStructure = {"folders": {}, "files": []} + self.setExtractionState(False) def updateTreeWidget(self, structure): self.treeWidget.clear() - self.treeWidget.setColumnCount(3) - self.treeWidget.setHeaderLabels(["Name", "Offset", "Size", "Source"]) + self.treeWidget.setColumnCount(4) + self.treeWidget.setHeaderLabels(["Name", "Duration", "Source", "Size", "Offset"]) self.addItems(None, structure) self.treeWidget.expandAll() - self.treeWidget.header().setSectionResizeMode(0, QHeaderView.Stretch) - self.treeWidget.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) - self.treeWidget.header().setSectionResizeMode(2, QHeaderView.ResizeToContents) + + self.treeWidget.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.treeWidget.header().setSectionResizeMode(1, QHeaderView.Stretch) + self.treeWidget.header().setSectionResizeMode(2, QHeaderView.Stretch) + self.treeWidget.header().setSectionResizeMode(3, QHeaderView.Stretch) + self.treeWidget.setHeaderHidden(False) self.treeWidget.setEditTriggers(QAbstractItemView.NoEditTriggers) self.treeWidget.setDragDropMode(QAbstractItemView.NoDragDrop) + self.treeWidget.itemClicked.connect(self.updateAudioPreview) + + def updateAudioPreview(self, item, column): + print(item.text(0)) def addItems(self, parent, element): for folder_name in sorted(element.get("folders", {}).keys()): folder_content = element["folders"][folder_name] - folder_item = QTreeWidgetItem([folder_name, "", "", ""]) + folder_item = QTreeWidgetItem([folder_name, "", "", "", ""]) folder_item.setFlags(folder_item.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable) folder_item.setCheckState(0, Qt.Unchecked) if parent is None: @@ -256,7 +300,8 @@ class AnimeWwise(QMainWindow): self.addItems(folder_item, folder_content) for file in sorted(element.get("files", [])): - file_item = QTreeWidgetItem([str(file[0]), str(hex(file[1])), str(file[2]), str(file[3])]) + file_meta = file[1] + file_item = QTreeWidgetItem([file[0], f'{round(file_meta["metadata"]["duration"], 2)} seconds', file_meta["source"], str(file_meta["size"]), str(hex(file_meta["offset"]))]) file_item.setFlags(file_item.flags() | Qt.ItemIsUserCheckable) file_item.setCheckState(0, Qt.Unchecked) if parent is None: @@ -266,12 +311,15 @@ class AnimeWwise(QMainWindow): # page 3 - extraction def extractItems(self, _all): + self.setFolder(folder="output") + if self.folders["output"] == "": QMessageBox.warning(None, "Warning", "Missing output folder !", QMessageBox.Ok) return checked_items = [] + # todo: use file structure instead of tree view def check_items(item, _all): if item.checkState(0) == Qt.Checked or _all: if item.text(1) != "": @@ -282,13 +330,11 @@ class AnimeWwise(QMainWindow): for i in range(self.treeWidget.topLevelItemCount()): check_items(self.treeWidget.topLevelItem(i), _all) - self.tabs.setTabEnabled(1, False) - self.tabs.setTabEnabled(2, False) - self.tabs.setCurrentIndex(2) + self.setExtractionState(False) # yet another block of threading bs self.backgroundThread = QThread() - self.backgroundWorker = BackgroundWorker("extract", self.extract, {"input": self.folders["input"], "files": checked_items, "format": self.outputFormat.currentText()[:3], "output": self.folders["output"]}) + self.backgroundWorker = BackgroundWorker("extract", self.extract, {"input": self.folders["input"], "files": checked_items, "format": self.format, "output": self.folders["output"]}) self.backgroundWorker.moveToThread(self.backgroundThread) self.backgroundThread.started.connect(self.backgroundWorker.run) self.backgroundWorker.finished.connect(self.handleFinished) @@ -310,18 +356,16 @@ class AnimeWwise(QMainWindow): return { "name": item.text(0), "path": path[:-1] if path[0] in ["changed_files", "new_files"] else path[1:-1], - "source": item.text(3), - "offset": int(item.text(1), 16), - "size": int(item.text(2)) + "source": item.text(2), + "offset": int(item.text(4), 16), + "size": int(item.text(3)) } # misc def resetApp(self): self.resetTreeWidget() self.extract.reset() - self.tabs.setTabEnabled(0, True) - self.tabs.setTabEnabled(1, False) - self.tabs.setTabEnabled(2, False) + self.setExtractionState(False) print("Reset !") def _appendText(self, text): diff --git a/extract.py b/extract.py index d7b4bd0..b9b8613 100644 --- a/extract.py +++ b/extract.py @@ -1,9 +1,10 @@ import os import io +import wwise import tempfile import wavescan -import subprocess import platform +import subprocess from mapper import Mapper from allocator import Allocator from filereader import FileReader @@ -62,7 +63,7 @@ class WwiseExtract: hdiff_files = self.get_hdiff_files(data, hdiff_data, filename) files = self.compare_diff(files, hdiff_files) - self.map_names(files, filename, hdiff is not None) + self.map_names(files, filename, hdiff is not None, data) def compare_diff(self, old, new): old_dict = {file[0]:file[2] for file in old} @@ -112,7 +113,7 @@ class WwiseExtract: return files - def map_names(self, files, filename, hdiff=False, skip_source=True): + def map_names(self, files, filename, hdiff=False, data=None, skip_source=True): # disable skip source if required mapper = self.mapper base = self.file_structure @@ -128,6 +129,18 @@ class WwiseExtract: else: key = None + file_data = { + "source": file[3], + "size": file[2], + "offset": file[1], + "metadata": {} + } + + wem_data = data[file_data["offset"]:file_data["offset"]+file_data["size"]] + parsed_wem = wwise.parse_wwise(FileReader(io.BytesIO(wem_data), "little")) + + file_data["metadata"] = parsed_wem + if key is not None: if hdiff: if file in old_files[0]: @@ -139,7 +152,7 @@ class WwiseExtract: if skip_source: parts = parts[1:] - self.add_to_structure(parts, [file[1], file[2], file[3]]) + self.add_to_structure(parts, file_data) else: temp = base["folders"] @@ -161,7 +174,7 @@ class WwiseExtract: if "unmapped" not in temp: temp["unmapped"] = {"folders": {}, "files": []} - temp["unmapped"]["files"].append(file) + temp["unmapped"]["files"].append([file[0], file_data]) self.file_structure = base @@ -175,7 +188,7 @@ class WwiseExtract: current_level = current_level["folders"][part] if "files" not in current_level: current_level["files"] = [] - current_level["files"].append([parts[-1], meta[0], meta[1], meta[2]]) + current_level["files"].append([parts[-1], meta]) ### extracting files ### diff --git a/gui.ui b/gui.ui index 5b633ec..6e641bb 100644 --- a/gui.ui +++ b/gui.ui @@ -5,24 +5,27 @@ Qt::NonModal + + true + 0 0 1100 - 800 + 900 1100 - 800 + 900 1100 - 800 + 900 @@ -39,7 +42,7 @@ - 1 + 0 true @@ -210,7 +213,7 @@ 0 20 1081 - 591 + 551 @@ -235,135 +238,49 @@ Search something... - - - - Extract - - + - 9 - 9 - 1061 - 601 + 0 + 580 + 1081 + 31 - + - - - - - Output folder - - - - - - - true - - - - - - - Select - - - - - - - - - - - Output format - - - - - - - - - - - - - - - - Qt::Horizontal + + + sample_long_name.wem | Duration : 00:00 | Stereo (48000Hz) - - - - - - - Total progress - - - - - - - 0 - - - - - - - - - - - Per file progress - - - - - - - 0 - - - - - - - - - - - Qt::Horizontal + + + - - - - - Extract All - - - - - - - Extract Selected - - - - + + + false + + + Play + + + + + + + false + + + Stop + + @@ -373,9 +290,9 @@ 10 - 640 + 720 1081 - 131 + 151 @@ -394,6 +311,54 @@ true + + + + 16 + 650 + 1071 + 61 + + + + + + + + + Total progress + + + + + + + 0 + + + + + + + + + + + Per file progress + + + + + + + 0 + + + + + + + @@ -412,7 +377,40 @@ + + + Extract + + + + Output format + + + + + + + + + + + Other + + + + + + + + View + + + + + + + @@ -439,6 +437,64 @@ Exit + + + false + + + Extract All + + + + + false + + + Extract Selected + + + + + Report a bug + + + + + Source code + + + + + Discord + + + + + false + + + format + + + false + + + + + false + + + Expand all + + + + + false + + + Collapse all + + inputPath diff --git a/version.json b/version.json new file mode 100644 index 0000000..8e94654 --- /dev/null +++ b/version.json @@ -0,0 +1,4 @@ +{ + "version": 21, + "mapsVersion": 89 +} \ No newline at end of file diff --git a/wwise.py b/wwise.py new file mode 100644 index 0000000..056388e --- /dev/null +++ b/wwise.py @@ -0,0 +1,127 @@ +# wwise riff header parser +# thanks to hcs and bnnm + +def parse_wwise(reader): + header = reader.ReadBytes(4) + + # endian check header + if header == b"RIFX": + reader.endianness = "big" + elif header == b"RIFF": + reader.endianness = "little" + else: + raise Exception("invalid header") + + # additional check + reader.SetBufferPos(0x08) + check = reader.ReadBytes(4) + + if check != b"WAVE" and check != "XWMA": + raise Exception("invalid file") + + # read chunks + reader.SetBufferPos(0x0C) + + chunks = {} + + while reader.GetBufferPos() < reader.GetStreamLength(): + # relevants chunks types + # "fmt " + # "data" + # "JUNK" + + chunk_type = reader.ReadBytes(4) + + if chunk_type not in [b"fmt ", b"JUNK", b"data"]: + raise Exception("invalid chunk") + + formatted_chunk_type = chunk_type.decode("utf-8").replace(" ", "") + chunk_length = reader.ReadUInt32() + chunks[formatted_chunk_type] = { + "length": chunk_length, + "offset": reader.GetBufferPos(), + "data": reader.ReadBytes(chunk_length) + } + + # reader fmt header + if chunks["fmt"]["length"] < 0x10: + raise Exception("invalid fmt chunk length") + + reader.SetBufferPos(chunks["fmt"]["offset"]) + + metadata = { + "format": reader.ReadUInt16(), + "channels": reader.ReadUInt16(), + "sampleRate": reader.ReadUInt32(), + "avgBitrate": reader.ReadUInt32(), + "blockSize": reader.ReadUInt16(), + "bitsPerSample": reader.ReadUInt16(), + "extraSize": 0, + "channelLayout": None, + "channelType": None, + "codec": None, + "layoutType": None, + "interleaveBlockSize": None, + "numSamples": None, + "duration": None + } + + if chunks["fmt"]["length"] > 0x10 and metadata["format"] != 0x0165 and metadata["format"] != 0x0166: + metadata["extraSize"] = reader.ReadUInt16() + + if metadata["extraSize"] >= 0x06: + metadata["channelLayout"] = reader.ReadUInt32() + + if metadata["channelLayout"] & 0xFF == metadata["channels"]: + metadata["channelType"] = (metadata["channelLayout"] >> 8) & 0x0F + metadata["channelLayout"] = metadata["channelLayout"] >> 12 + + if metadata["format"] == 0x0166: + raise Exception("XMA2WAVEFORMATEX in fmt") + + # parse codec + codecs = { + 0x0001: "PCM", + 0x0002: "IMA", + 0x0069: "IMA", + 0x0161: "XWLA", + 0x0162: "XWMA", + 0x0165: "XMA2", + 0x0166: "XMA2", + 0xAAC0: "AAC", + 0xFFF0: "DSP", + 0xFFFB: "HEVAG", + 0xFFFC: "ATRAC9", + 0xFFFE: "PCM", + 0xFFFF: "VORBIS", + 0x3039: "OPUSNX", + 0x3040: "OPUS", + 0x3041: "OPUSWW", + 0x8311: "PTADPCM" + } + + # genshin should be PTADPCM + # hsr and zzz should be VORBIS + + if metadata["format"] not in codecs: + raise Exception("unknown codec") + + codec = codecs[metadata["format"]] + + if codec not in ["PTADPCM", "VORBIS"]: # Platinum "PtADPCM" custom ADPCM for Wwise + raise Exception(f"unhandled codec -> {codec}") + + metadata["codec"] = codec + + # parse more infos + if metadata["codec"] == "PTADPCM": + metadata["layoutType"] = "interleave" + metadata["interleaveBlockSize"] = metadata["blockSize"] // metadata["channels"] + + metadata["numSamples"] = int((chunks["data"]["length"] / (metadata["channels"] * metadata["interleaveBlockSize"])) * (2 + (metadata["interleaveBlockSize"] - 0x05) * 2)) + metadata["duration"] = metadata["numSamples"] / metadata["sampleRate"] + + return metadata + + # TODO: parse VORBIS + # TODO: rewrite codec ?