diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2a91ee38..20ff3c2a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "aotuv_lancer_vorbis_sys" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc4fd1a61860d2f1198b60bedd30910eaffa978f1ee6214dfb24ac70d589225" +dependencies = [ + "cc", + "ogg_next_sys", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -56,6 +66,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -1070,6 +1086,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.4.1" @@ -2198,6 +2220,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2708,6 +2741,24 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "ogg_next_sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2d7a48e247c2bb07e633aefb65a38648ea58c7eedd4e4408a5861721ab049b" +dependencies = [ + "cc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3184,6 +3235,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "symphonia", "tempfile", "thiserror 1.0.69", "tokio", @@ -3543,7 +3595,7 @@ dependencies = [ [[package]] name = "ritoshark" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_anim", "rs_audio", @@ -3565,7 +3617,7 @@ dependencies = [ [[package]] name = "rs_anim" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3576,16 +3628,19 @@ dependencies = [ [[package]] name = "rs_audio" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ + "lewton", + "ogg", "rs_io", "thiserror 2.0.18", + "vorbis_rs", ] [[package]] name = "rs_bin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "indexmap 2.14.0", "rs_hash", @@ -3597,12 +3652,12 @@ dependencies = [ [[package]] name = "rs_file" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" [[package]] name = "rs_hash" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "thiserror 2.0.18", "xxhash-rust", @@ -3611,7 +3666,7 @@ dependencies = [ [[package]] name = "rs_io" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "memmap2", "rs_math", @@ -3621,7 +3676,7 @@ dependencies = [ [[package]] name = "rs_luabin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "thiserror 2.0.18", @@ -3630,7 +3685,7 @@ dependencies = [ [[package]] name = "rs_mapgeo" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3641,7 +3696,7 @@ dependencies = [ [[package]] name = "rs_math" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "glam", ] @@ -3649,7 +3704,7 @@ dependencies = [ [[package]] name = "rs_mesh" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "rs_math", @@ -3659,7 +3714,7 @@ dependencies = [ [[package]] name = "rs_rman" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_io", "thiserror 2.0.18", @@ -3669,7 +3724,7 @@ dependencies = [ [[package]] name = "rs_rst" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "indexmap 2.14.0", "rs_hash", @@ -3680,7 +3735,7 @@ dependencies = [ [[package]] name = "rs_tex" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "ddsfile", "image", @@ -3695,7 +3750,7 @@ dependencies = [ [[package]] name = "rs_troybin" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "rs_hash", "rs_io", @@ -3705,7 +3760,7 @@ dependencies = [ [[package]] name = "rs_wad" version = "0.1.0" -source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=2ed5554828764cf0ce9f3f426ed6352fea39578a#2ed5554828764cf0ce9f3f426ed6352fea39578a" +source = "git+https://github.com/RitoShark/RitoShark-Crates?rev=24fe6b3#24fe6b3665edbf5b67977918571c8fa6769c74c2" dependencies = [ "flate2", "rs_hash", @@ -4350,6 +4405,153 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-aac", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-isomp4", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -5473,6 +5675,20 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vorbis_rs" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49c5da94d280f7a27e8c937e9b73df2da3e23a2583f48471fd8fb4c72f9c1933" +dependencies = [ + "aotuv_lancer_vorbis_sys", + "errno", + "getrandom 0.4.2", + "ogg_next_sys", + "thiserror 2.0.18", + "tinyvec", +] + [[package]] name = "vswhom" version = "0.1.0" diff --git a/src-tauri/crates/quartz-lib/Cargo.toml b/src-tauri/crates/quartz-lib/Cargo.toml index c1de839d..e86621dc 100644 --- a/src-tauri/crates/quartz-lib/Cargo.toml +++ b/src-tauri/crates/quartz-lib/Cargo.toml @@ -42,6 +42,11 @@ zstd = "0.12" # Binary parsing for BNK/WPK/WEM audio byteorder = "1.5" +# Decoding user-supplied audio (mp3/flac/ogg/wav/m4a) down to PCM samples, which +# is where rs_audio takes over. Reading user audio files is explicitly out of +# scope for that crate, so it is the app's side of the boundary. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aac", "isomp4"] } + # Data indexmap = { version = "2.1", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] } @@ -52,10 +57,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"] # Logging (macros only) tracing = "0.1" -# RitoShark crates — BIN/format handling + TEX decode. Pinned to a rev branched -# off d6af5ac (the rev Flint uses) that adds Wad::from_reader_toc + seek-per-chunk -# reads for lightweight WAD indexing/extraction (branch: wad-toc-seek). -ritoshark = { git = "https://github.com/RitoShark/RitoShark-Crates", rev = "2ed5554828764cf0ce9f3f426ed6352fea39578a", features = ["tex", "anim", "mesh"] } +# RitoShark crates — BIN/format handling, TEX decode, audio (bnk/wpk/wem). +ritoshark = { git = "https://github.com/RitoShark/RitoShark-Crates", rev = "24fe6b3", features = ["tex", "anim", "mesh", "audio"] } [dev-dependencies] # Scratch dirs for the live-install extraction tests (auto-removed on drop). diff --git a/src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin b/src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin deleted file mode 100644 index a405d061..00000000 Binary files a/src-tauri/crates/quartz-lib/resources/packed_codebooks_aoTuV_603.bin and /dev/null differ diff --git a/src-tauri/crates/quartz-lib/src/audio/bank.rs b/src-tauri/crates/quartz-lib/src/audio/bank.rs new file mode 100644 index 00000000..25b83a97 --- /dev/null +++ b/src-tauri/crates/quartz-lib/src/audio/bank.rs @@ -0,0 +1,307 @@ +/* BNK/WPK containers and WEM audio, backed by ritoshark::audio. + +Edits modify the parsed container rather than rebuilding it, so a bank keeps its header revision, +its bank id, its object hierarchy and every section this app does not model, and a package keeps +its entry names, its slot order and its dead slots. Rebuilding on each save is what produces a +file the engine cannot load even though the audio inside it is fine. + +Replacement audio is encoded to Wwise Vorbis — the one codec the game ships — which is what +removed the need for an external Wwise toolchain. */ + +use std::collections::HashSet; + +use ritoshark::audio::{ + AudioFormat, Bnk, PcmAudio, Wem, WemCodec, Wpk, encode_vorbis, encode_vorbis_like, +}; +use ritoshark::prelude::{Parse, Serialize}; +use serde::{Deserialize, Serialize as SerdeSerialize}; + +/// Vorbis convention: -0.2 worst, 1.0 best. 0.5 is the crate's documented default. +const VORBIS_QUALITY: f32 = 0.5; + +#[derive(Debug, Clone)] +pub struct AudioEntry { + pub id: u32, + pub data: Vec, +} + +/// A decoded WEM as playable bytes. +#[derive(Debug, Clone, SerdeSerialize, Deserialize)] +pub struct DecodedAudio { + pub data: Vec, + /// `"ogg"` or `"wav"`. + pub format: String, + pub sample_rate: Option, +} + +/// Either Wwise container, parsed. Both hold WEM payloads addressed by a numeric id. +pub enum Bank { + Bnk(Bnk), + Wpk(Wpk), +} + +impl Bank { + pub fn parse(data: &[u8]) -> Result { + match data.get(..4) { + Some(b"BKHD") => Bnk::from_bytes(data) + .map(Self::Bnk) + .map_err(|e| format!("Failed to parse BNK: {e}")), + Some(b"r3d2") => Wpk::from_bytes(data) + .map(Self::Wpk) + .map_err(|e| format!("Failed to parse WPK: {e}")), + Some(magic) => Err(format!( + "Unknown audio format (magic: {:02X}{:02X}{:02X}{:02X})", + magic[0], magic[1], magic[2], magic[3] + )), + None => Err("Audio file too small".into()), + } + } + + /* A WPK entry whose name is not ".wem" has no id to address it by, so it is left out + rather than listed under a placeholder nothing could resolve. */ + pub fn entries(&self) -> Vec { + match self { + Self::Bnk(bnk) => bnk + .wems() + .into_iter() + .map(|(id, data)| AudioEntry { + id, + data: data.to_vec(), + }) + .collect(), + Self::Wpk(wpk) => wpk + .wems() + .into_iter() + .filter_map(|(id, _, data)| { + Some(AudioEntry { + id: id?, + data: data.to_vec(), + }) + }) + .collect(), + } + } + + pub fn ids(&self) -> Vec { + self.entries().into_iter().map(|e| e.id).collect() + } + + pub fn entry(&self, id: u32) -> Option<&[u8]> { + match self { + Self::Bnk(bnk) => bnk.wem(id), + Self::Wpk(wpk) => wpk.wem(id), + } + } + + pub fn insert(&mut self, id: u32, data: Vec) -> Result<(), String> { + match self { + Self::Bnk(bnk) => bnk.insert_wem(id, data), + Self::Wpk(wpk) => wpk.insert_wem(id, data), + } + .map_err(|e| format!("Failed to write entry {id}: {e}")) + } + + pub fn remove(&mut self, id: u32) -> Result<(), String> { + match self { + Self::Bnk(bnk) => bnk.remove_wem(id), + Self::Wpk(wpk) => wpk.remove_wem(id), + } + .map_err(|e| format!("Failed to remove entry {id}: {e}")) + } + + pub fn to_bytes(&self) -> Result, String> { + match self { + Self::Bnk(bnk) => bnk.to_bytes(), + Self::Wpk(wpk) => wpk.to_bytes(), + } + .map_err(|e| format!("Failed to serialize audio bank: {e}")) + } +} + +/// Every embedded WEM in a BNK or WPK buffer, in container order. +pub fn all_entries(data: &[u8]) -> Result, String> { + Ok(Bank::parse(data)?.entries()) +} + +/** Applies an edited entry set to the container it came from. + +Starting from the original is what keeps the header, the hierarchy and every unmodelled section +intact. Ids the caller no longer lists are removed, so a bank saved after deleting a sound really +loses it. */ +pub fn save_with_entries(original: &[u8], entries: &[AudioEntry]) -> Result, String> { + let mut bank = Bank::parse(original)?; + + let keep: HashSet = entries.iter().map(|e| e.id).collect(); + for id in bank.ids() { + if !keep.contains(&id) { + bank.remove(id)?; + } + } + for entry in entries { + bank.insert(entry.id, entry.data.clone())?; + } + + bank.to_bytes() +} + +/// Decodes a WEM to a playable stream — Ogg for Wwise Vorbis, WAV for PCM. +pub fn decode_wem(data: &[u8]) -> Result { + let decoded = Wem::new(data) + .and_then(|wem| wem.decode()) + .map_err(|e| format!("Failed to decode WEM: {e}"))?; + + Ok(DecodedAudio { + data: decoded.data, + format: match decoded.format { + AudioFormat::Ogg => "ogg", + AudioFormat::Wav => "wav", + } + .into(), + sample_rate: Some(decoded.sample_rate), + }) +} + +/// Decodes a WEM all the way to interleaved 16-bit samples. +pub fn wem_to_pcm(data: &[u8]) -> Result { + Wem::new(data) + .and_then(|wem| wem.to_pcm()) + .map_err(|e| format!("Failed to decode WEM: {e}")) +} + +/// Wraps interleaved 16-bit samples in a RIFF/WAVE header. +pub fn pcm_to_wav(audio: &PcmAudio) -> Vec { + let data_len = audio.samples.len() * 2; + let byte_rate = audio.sample_rate * u32::from(audio.channels) * 2; + let block_align = audio.channels * 2; + + let mut out = Vec::with_capacity(44 + data_len); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&audio.channels.to_le_bytes()); + out.extend_from_slice(&audio.sample_rate.to_le_bytes()); + out.extend_from_slice(&byte_rate.to_le_bytes()); + out.extend_from_slice(&block_align.to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data_len as u32).to_le_bytes()); + for sample in &audio.samples { + out.extend_from_slice(&sample.to_le_bytes()); + } + out +} + +/// Encodes samples into a Wwise Vorbis WEM, cloning `reference`'s header when there is one. +pub fn encode_wem(audio: &PcmAudio, reference: Option<&[u8]>) -> Result, String> { + /* A template only works if the reference really is Wwise Vorbis; League ships nothing else, + but a bank holding PCM would otherwise fail outright. */ + let template = reference + .filter(|bytes| Wem::new(bytes).is_ok_and(|wem| wem.format().codec == WemCodec::Vorbis)); + + match template { + Some(bytes) => encode_vorbis_like(bytes, audio, VORBIS_QUALITY), + None => encode_vorbis(audio, VORBIS_QUALITY), + } + .map_err(|e| format!("Failed to encode WEM: {e}")) +} + +/** Turns whatever the user supplied into an embeddable WEM. + +Anything that already parses as one is embedded verbatim, which keeps a WEM lifted out of another +bank bit-identical. Everything else is decoded to samples and encoded. */ +pub fn to_wem(data: &[u8], reference: Option<&[u8]>) -> Result, String> { + if Wem::new(data).is_ok() { + return Ok(data.to_vec()); + } + let pcm = super::decode::decode_any(data)?; + encode_wem(&pcm, reference) +} + +/// Scales a WEM by `gain_db`, re-encoding into the codec it already used. +pub fn amplify_wem(data: &[u8], gain_db: f32) -> Result, String> { + let mut pcm = wem_to_pcm(data)?; + let factor = 10f32.powf(gain_db / 20.0); + for sample in &mut pcm.samples { + *sample = (f32::from(*sample) * factor).clamp(f32::from(i16::MIN), f32::from(i16::MAX)) + as i16; + } + encode_wem(&pcm, Some(data)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ritoshark::audio::{BnkSection, silence}; + + fn bank_with_one_wem() -> Vec { + let mut bnk = Bnk::new(); + bnk.sections.push(BnkSection { + tag: *b"BKHD", + data: vec![0x91, 0, 0, 0, 0xEF, 0xBE, 0xAD, 0xDE], + }); + bnk.sections.push(BnkSection { + tag: *b"HIRC", + data: vec![0, 0, 0, 0], + }); + bnk.insert_wem(100, silence(44100, 1, 4096).unwrap()).unwrap(); + bnk.to_bytes().unwrap() + } + + #[test] + fn saving_an_edited_entry_set_keeps_the_header_and_hierarchy() { + let original = bank_with_one_wem(); + let entries = vec![AudioEntry { + id: 100, + data: silence(44100, 1, 2048).unwrap(), + }]; + + let saved = save_with_entries(&original, &entries).unwrap(); + let Bank::Bnk(bnk) = Bank::parse(&saved).unwrap() else { + panic!("still a bnk"); + }; + + assert_eq!(bnk.version(), Some(0x91), "header revision must survive"); + assert_eq!(bnk.bank_id(), Some(0xDEADBEEF), "bank id must survive"); + assert!( + bnk.sections.iter().any(|s| s.tag == *b"HIRC"), + "the object hierarchy must survive a save" + ); + } + + #[test] + fn saving_drops_entries_the_caller_no_longer_lists() { + let original = bank_with_one_wem(); + let saved = save_with_entries(&original, &[]).unwrap(); + assert!(Bank::parse(&saved).unwrap().entries().is_empty()); + } + + #[test] + fn an_existing_wem_is_embedded_verbatim() { + let wem = silence(32000, 2, 64).unwrap(); + assert_eq!(to_wem(&wem, None).unwrap(), wem); + } + + #[test] + fn amplifying_keeps_the_stream_playable_and_vorbis() { + let original = silence(44100, 1, 4096).unwrap(); + let louder = amplify_wem(&original, 6.0).unwrap(); + + let wem = Wem::new(&louder).expect("amplified audio must still be a wem"); + assert_eq!(wem.format().codec, WemCodec::Vorbis); + assert_eq!(wem.format().sample_rate, 44100); + } + + #[test] + fn pcm_round_trips_through_the_wav_wrapper() { + let pcm = PcmAudio::new(22050, 2, vec![1, -1, 300, -300]); + let wav = pcm_to_wav(&pcm); + + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(u32::from_le_bytes(wav[24..28].try_into().unwrap()), 22050); + assert_eq!(u16::from_le_bytes(wav[22..24].try_into().unwrap()), 2); + assert_eq!(wav.len(), 44 + 8); + } +} diff --git a/src-tauri/crates/quartz-lib/src/audio/bnk.rs b/src-tauri/crates/quartz-lib/src/audio/bnk.rs deleted file mode 100644 index 27d09c1c..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/bnk.rs +++ /dev/null @@ -1,345 +0,0 @@ -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; -use std::io::{Cursor, Read, Write}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudioEntryInfo { - pub id: u32, - pub size: u32, -} - -#[derive(Debug, Clone)] -pub struct AudioEntry { - pub id: u32, - pub data: Vec, -} - -#[derive(Debug)] -pub struct BnkFile { - pub version: u32, - pub bank_id: u32, - /// DIDX entries (file_id, offset_in_data, length) - entries: Vec<(u32, u32, u32)>, - /// Absolute offset of the DATA section payload in the original buffer - data_section_offset: usize, - pub hirc_bytes: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BnkInfo { - pub format: String, - pub version: u32, - pub bank_id: u32, - pub entry_count: usize, - pub entries: Vec, - pub has_hirc: bool, -} - -impl BnkFile { - pub fn parse(data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - let len = data.len() as u64; - - let mut magic = [0u8; 4]; - cursor - .read_exact(&mut magic) - .map_err(|e| format!("Failed to read BNK magic: {e}"))?; - if &magic != b"BKHD" { - return Err("Not a valid BNK file — missing BKHD header".into()); - } - let bkhd_len = cursor - .read_u32::() - .map_err(|e| format!("Failed to read BKHD length: {e}"))?; - let version = cursor - .read_u32::() - .map_err(|e| format!("Failed to read BNK version: {e}"))?; - let bank_id = cursor - .read_u32::() - .map_err(|e| format!("Failed to read bank ID: {e}"))?; - - let bkhd_remaining = bkhd_len.saturating_sub(8) as u64; - cursor.set_position(cursor.position() + bkhd_remaining); - - let mut entries: Vec<(u32, u32, u32)> = Vec::new(); - let mut data_section_offset: usize = 0; - let mut hirc_bytes: Option> = None; - - while cursor.position() + 8 <= len { - let mut section_magic = [0u8; 4]; - if cursor.read_exact(&mut section_magic).is_err() { - break; - } - let section_len = match cursor.read_u32::() { - Ok(v) => v, - Err(_) => break, - }; - let section_start = cursor.position() as usize; - - match §ion_magic { - b"DIDX" => { - let entry_count = section_len / 12; - entries.reserve(entry_count as usize); - for _ in 0..entry_count { - let file_id = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - let offset = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - let length = cursor - .read_u32::() - .map_err(|e| format!("DIDX read error: {e}"))?; - entries.push((file_id, offset, length)); - } - } - b"DATA" => { - data_section_offset = section_start; - } - b"HIRC" => { - let hirc_end = section_start + section_len as usize; - if hirc_end <= data.len() { - hirc_bytes = Some(data[section_start..hirc_end].to_vec()); - } - } - _ => {} - } - - cursor.set_position((section_start + section_len as usize) as u64); - } - - Ok(BnkFile { - version, - bank_id, - entries, - data_section_offset, - hirc_bytes, - }) - } - - pub fn info(&self) -> BnkInfo { - BnkInfo { - format: "bnk".into(), - version: self.version, - bank_id: self.bank_id, - entry_count: self.entries.len(), - entries: self - .entries - .iter() - .map(|&(id, _, size)| AudioEntryInfo { id, size }) - .collect(), - has_hirc: self.hirc_bytes.is_some(), - } - } - - pub fn read_entry_data<'a>(&self, data: &'a [u8], file_id: u32) -> Result<&'a [u8], String> { - let entry = self - .entries - .iter() - .find(|e| e.0 == file_id) - .ok_or_else(|| format!("Audio entry {file_id} not found in BNK"))?; - - let start = self.data_section_offset + entry.1 as usize; - let end = start + entry.2 as usize; - - if end > data.len() { - return Err(format!( - "Audio entry {file_id} data out of bounds ({}..{} > {})", - start, - end, - data.len() - )); - } - - Ok(&data[start..end]) - } - - pub fn read_all_entries(&self, data: &[u8]) -> Result, String> { - let mut result = Vec::with_capacity(self.entries.len()); - for &(id, offset, length) in &self.entries { - let start = self.data_section_offset + offset as usize; - let end = start + length as usize; - if end > data.len() { - return Err(format!("Audio entry {id} data out of bounds")); - } - result.push(AudioEntry { - id, - data: data[start..end].to_vec(), - }); - } - Ok(result) - } -} - -pub fn parse_bnk_metadata(data: &[u8]) -> Result { - let bnk = BnkFile::parse(data)?; - Ok(bnk.info()) -} - -pub fn read_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - let bnk = BnkFile::parse(data)?; - Ok(bnk.read_entry_data(data, file_id)?.to_vec()) -} - -// --------------------------------------------------------------------------- -// Writer -// --------------------------------------------------------------------------- - -const ALIGNMENT: usize = 16; - -fn align_up(val: usize, alignment: usize) -> usize { - let rem = val % alignment; - if rem == 0 { - val - } else { - val + (alignment - rem) - } -} - -/// Produces a minimal BKHD + DIDX + DATA bank (version 0x86). -pub fn write_bnk(entries: &[AudioEntry]) -> Vec { - let bkhd_section_len: u32 = 0x14; - - let didx_size = (entries.len() * 12) as u32; - - let mut offsets = Vec::with_capacity(entries.len()); - let mut data_size: usize = 0; - for entry in entries { - let aligned = align_up(data_size, ALIGNMENT); - offsets.push(aligned as u32); - data_size = aligned + entry.data.len(); - } - - let total = 8 + bkhd_section_len as usize + 8 + didx_size as usize + 8 + data_size; - - let mut buf = Vec::with_capacity(total); - - buf.write_all(b"BKHD").unwrap(); - buf.write_u32::(bkhd_section_len).unwrap(); - buf.write_u32::(0x86).unwrap(); - buf.write_u32::(0).unwrap(); - buf.write_u32::(0x17705D3E).unwrap(); - buf.write_all(&[0u8; 8]).unwrap(); - - buf.write_all(b"DIDX").unwrap(); - buf.write_u32::(didx_size).unwrap(); - for (i, entry) in entries.iter().enumerate() { - buf.write_u32::(entry.id).unwrap(); - buf.write_u32::(offsets[i]).unwrap(); - buf.write_u32::(entry.data.len() as u32) - .unwrap(); - } - - buf.write_all(b"DATA").unwrap(); - buf.write_u32::(data_size as u32).unwrap(); - let data_start = buf.len(); - buf.resize(data_start + data_size, 0); - for (i, entry) in entries.iter().enumerate() { - let dst = data_start + offsets[i] as usize; - buf[dst..dst + entry.data.len()].copy_from_slice(&entry.data); - } - - buf -} - -pub fn replace_bnk_entry(data: &[u8], file_id: u32, new_wem: &[u8]) -> Result, String> { - let bnk = BnkFile::parse(data)?; - let mut entries = bnk.read_all_entries(data)?; - - let entry = entries - .iter_mut() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Entry {file_id} not found"))?; - entry.data = new_wem.to_vec(); - - Ok(write_bnk(&entries)) -} - -/// Minimal silence WEM (valid RIFF WAV with no audio data). -pub const SILENCE_WEM: &[u8] = &[ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // chunk size = 36 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6D, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // fmt size = 16 - 0x01, 0x00, // PCM format - 0x01, 0x00, // 1 channel - 0x44, 0xAC, 0x00, 0x00, // 44100 Hz - 0x88, 0x58, 0x01, 0x00, // byte rate - 0x02, 0x00, // block align - 0x10, 0x00, // 16 bits per sample - 0x64, 0x61, 0x74, 0x61, // "data" - 0x00, 0x00, 0x00, 0x00, // data size = 0 (silence) -]; - -pub fn silence_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - replace_bnk_entry(data, file_id, SILENCE_WEM) -} - -/// Rewrites BKHD/DIDX/DATA — HIRC is not preserved. -pub fn remove_bnk_entry(data: &[u8], file_id: u32) -> Result, String> { - let bnk = BnkFile::parse(data)?; - let mut entries = bnk.read_all_entries(data)?; - let before = entries.len(); - entries.retain(|e| e.id != file_id); - if entries.len() == before { - return Err(format!("Entry {file_id} not found")); - } - Ok(write_bnk(&entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_bnk() -> Vec { - let e1 = AudioEntry { - id: 100, - data: vec![0xAA; 32], - }; - let e2 = AudioEntry { - id: 200, - data: vec![0xBB; 16], - }; - write_bnk(&[e1, e2]) - } - - #[test] - fn roundtrip_write_parse() { - let bnk_data = make_test_bnk(); - let info = parse_bnk_metadata(&bnk_data).unwrap(); - assert_eq!(info.format, "bnk"); - assert_eq!(info.entry_count, 2); - assert_eq!(info.entries[0].id, 100); - assert_eq!(info.entries[0].size, 32); - assert_eq!(info.entries[1].id, 200); - assert_eq!(info.entries[1].size, 16); - } - - #[test] - fn read_entry_data() { - let bnk_data = make_test_bnk(); - let entry_data = read_bnk_entry(&bnk_data, 100).unwrap(); - assert_eq!(entry_data.len(), 32); - assert!(entry_data.iter().all(|&b| b == 0xAA)); - - let entry_data = read_bnk_entry(&bnk_data, 200).unwrap(); - assert_eq!(entry_data.len(), 16); - assert!(entry_data.iter().all(|&b| b == 0xBB)); - } - - #[test] - fn replace_entry() { - let bnk_data = make_test_bnk(); - let new_bnk = replace_bnk_entry(&bnk_data, 100, &[0xCC; 64]).unwrap(); - let info = parse_bnk_metadata(&new_bnk).unwrap(); - assert_eq!(info.entries[0].size, 64); - let entry_data = read_bnk_entry(&new_bnk, 100).unwrap(); - assert!(entry_data.iter().all(|&b| b == 0xCC)); - } - - #[test] - fn entry_not_found() { - let bnk_data = make_test_bnk(); - assert!(read_bnk_entry(&bnk_data, 999).is_err()); - } -} diff --git a/src-tauri/crates/quartz-lib/src/audio/decode.rs b/src-tauri/crates/quartz-lib/src/audio/decode.rs new file mode 100644 index 00000000..a48a68cd --- /dev/null +++ b/src-tauri/crates/quartz-lib/src/audio/decode.rs @@ -0,0 +1,162 @@ +/* Decoding user-supplied audio files down to PCM samples. + +rs_audio deliberately stops at PCM in both directions — reading mp3/flac/ogg is the application's +job — so this is where the app meets it. Everything a normal editor exports is handled in-process, +which is what let the external decoder binary go. */ + +use ritoshark::audio::PcmAudio; +use symphonia::core::audio::{AudioBufferRef, Signal}; +use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::conv::IntoSample; +use symphonia::core::formats::FormatOptions; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; + +/** Decodes MP3, FLAC, OGG/Vorbis, WAV or M4A/AAC into interleaved 16-bit samples. + +The container is identified from the bytes rather than a file extension, so a mislabelled file +still decodes and a renamed one does not silently take the wrong path. */ +pub fn decode_any(data: &[u8]) -> Result { + let source = MediaSourceStream::new(Box::new(std::io::Cursor::new(data.to_vec())), <_>::default()); + + let probed = symphonia::default::get_probe() + .format( + &Hint::new(), + source, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|_| { + "Unsupported audio file. Supported: WAV, MP3, OGG, FLAC, M4A and .wem.".to_string() + })?; + + let mut format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or("Audio file contains no decodable track")?; + let track_id = track.id; + + let mut decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| format!("No decoder for this audio codec: {e}"))?; + + let mut samples: Vec = Vec::new(); + let mut sample_rate = track.codec_params.sample_rate.unwrap_or(0); + let mut channels = track + .codec_params + .channels + .map(|c| c.count() as u16) + .unwrap_or(0); + + // The loop ends on the first packet error, which covers both a clean end of + // stream and a truncated tail — whatever decoded before it is still usable. + while let Ok(packet) = format.next_packet() { + if packet.track_id() != track_id { + continue; + } + + match decoder.decode(&packet) { + Ok(buffer) => { + let spec = *buffer.spec(); + if sample_rate == 0 { + sample_rate = spec.rate; + } + if channels == 0 { + channels = spec.channels.count() as u16; + } + append_interleaved(&buffer, &mut samples); + } + // A damaged packet costs one packet, not the whole file. + Err(symphonia::core::errors::Error::DecodeError(_)) => continue, + Err(e) => return Err(format!("Audio decode failed: {e}")), + } + } + + if samples.is_empty() { + return Err("Audio file decoded to no samples".into()); + } + if sample_rate == 0 || channels == 0 { + return Err("Audio file declares no sample rate or no channels".into()); + } + + Ok(PcmAudio::new(sample_rate, channels, samples)) +} + +/// Flattens a planar decode buffer into the interleaved layout the encoder wants. +fn append_interleaved(buffer: &AudioBufferRef<'_>, out: &mut Vec) { + macro_rules! planar { + ($buf:expr) => {{ + let channels = $buf.spec().channels.count(); + let frames = $buf.frames(); + out.reserve(frames * channels); + for frame in 0..frames { + for channel in 0..channels { + out.push($buf.chan(channel)[frame].into_sample()); + } + } + }}; + } + + match buffer { + AudioBufferRef::U8(b) => planar!(b), + AudioBufferRef::U16(b) => planar!(b), + AudioBufferRef::U24(b) => planar!(b), + AudioBufferRef::U32(b) => planar!(b), + AudioBufferRef::S8(b) => planar!(b), + AudioBufferRef::S16(b) => planar!(b), + AudioBufferRef::S24(b) => planar!(b), + AudioBufferRef::S32(b) => planar!(b), + AudioBufferRef::F32(b) => planar!(b), + AudioBufferRef::F64(b) => planar!(b), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wav(sample_rate: u32, channels: u16, frames: &[i16]) -> Vec { + let data_len = frames.len() * 2; + let mut out = Vec::new(); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&(36 + data_len as u32).to_le_bytes()); + out.extend_from_slice(b"WAVEfmt "); + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&channels.to_le_bytes()); + out.extend_from_slice(&sample_rate.to_le_bytes()); + out.extend_from_slice(&(sample_rate * u32::from(channels) * 2).to_le_bytes()); + out.extend_from_slice(&(channels * 2).to_le_bytes()); + out.extend_from_slice(&16u16.to_le_bytes()); + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data_len as u32).to_le_bytes()); + for sample in frames { + out.extend_from_slice(&sample.to_le_bytes()); + } + out + } + + #[test] + fn a_wav_decodes_to_the_samples_it_declares() { + let decoded = decode_any(&wav(44100, 1, &[0, 1000, -1000, 32767])).unwrap(); + assert_eq!(decoded.sample_rate, 44100); + assert_eq!(decoded.channels, 1); + assert_eq!(decoded.samples, vec![0, 1000, -1000, 32767]); + } + + #[test] + fn stereo_stays_interleaved() { + let decoded = decode_any(&wav(22050, 2, &[1, -1, 2, -2])).unwrap(); + assert_eq!(decoded.channels, 2); + assert_eq!(decoded.samples, vec![1, -1, 2, -2]); + } + + #[test] + fn a_non_audio_file_is_an_error_rather_than_silence() { + assert!(decode_any(b"not audio, just some bytes here at all").is_err()); + assert!(decode_any(&[]).is_err()); + } +} diff --git a/src-tauri/crates/quartz-lib/src/audio/mod.rs b/src-tauri/crates/quartz-lib/src/audio/mod.rs index 21ad6279..16f9c95a 100644 --- a/src-tauri/crates/quartz-lib/src/audio/mod.rs +++ b/src-tauri/crates/quartz-lib/src/audio/mod.rs @@ -1,6 +1,5 @@ -pub mod bnk; +pub mod bank; +pub mod decode; pub mod event_mapper; pub mod hirc; pub mod tree; -pub mod wem; -pub mod wpk; diff --git a/src-tauri/crates/quartz-lib/src/audio/tree.rs b/src-tauri/crates/quartz-lib/src/audio/tree.rs index 30310485..d623890a 100644 --- a/src-tauri/crates/quartz-lib/src/audio/tree.rs +++ b/src-tauri/crates/quartz-lib/src/audio/tree.rs @@ -6,10 +6,9 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use super::bnk::{self, AudioEntry}; +use super::bank::{self, AudioEntry}; use super::event_mapper::{self, EventMapping}; use super::hirc; -use super::wpk; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -47,7 +46,6 @@ pub struct BnkNode { #[serde(rename_all = "camelCase")] pub struct LoadBanksResult { pub tree: BnkNode, - pub audio_files: Vec, pub file_count: usize, #[serde(rename = "type")] pub kind: String, @@ -368,14 +366,7 @@ fn file_name(path: &str) -> String { /// Read all entries from a BNK or WPK buffer, sorted by id. fn read_entries(data: &[u8]) -> Result, String> { - if data.len() < 4 { - return Err("Audio file too small".into()); - } - let mut entries = match &data[0..4] { - b"BKHD" => bnk::BnkFile::parse(data)?.read_all_entries(data)?, - b"r3d2" => wpk::WpkFile::parse(data)?.read_all_entries(data)?, - _ => return Err("Unknown audio format".into()), - }; + let mut entries = bank::all_entries(data)?; entries.sort_by_key(|e| e.id); Ok(entries) } @@ -507,8 +498,7 @@ pub fn load_banks( &source_name }); - let audio_files: Vec = entries.iter().map(to_audio_data).collect(); - let file_count = audio_files.len(); + let file_count = entries.len(); let mut tree = group_audio_files(&entries, &mappings, &source_name); scope_ids(&mut tree, &scope_key, &[]); @@ -527,7 +517,6 @@ pub fn load_banks( Ok(Some(LoadBanksResult { tree, - audio_files, file_count, kind: final_type, })) diff --git a/src-tauri/crates/quartz-lib/src/audio/wem.rs b/src-tauri/crates/quartz-lib/src/audio/wem.rs deleted file mode 100644 index 1cb3ce6f..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/wem.rs +++ /dev/null @@ -1,1376 +0,0 @@ -//! WEM → OGG/WAV decoder (Rust port of ww2ogg). -//! -//! Converts Wwise WEM audio files (RIFF-wrapped Vorbis) to standard OGG Vorbis -//! or WAV PCM that browsers can play via Web Audio API. - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DecodedAudio { - pub data: Vec, - pub format: String, // "ogg" or "wav" - pub sample_rate: Option, -} - -const CODEBOOK_DATA: &[u8] = include_bytes!("../../resources/packed_codebooks_aoTuV_603.bin"); - -/// The packed Vorbis codebook embedded in the decoder. -pub fn codebook_bytes() -> &'static [u8] { - CODEBOOK_DATA -} - -// --------------------------------------------------------------------------- -// OGG CRC lookup table -// --------------------------------------------------------------------------- - -#[rustfmt::skip] -const CRC_LOOKUP: [u32; 256] = [ - 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, - 0x130476dc, 0x17c56b6b, 0x1a864db2, 0x1e475005, - 0x2608edb8, 0x22c9f00f, 0x2f8ad6d6, 0x2b4bcb61, - 0x350c9b64, 0x31cd86d3, 0x3c8ea00a, 0x384fbdbd, - 0x4c11db70, 0x48d0c6c7, 0x4593e01e, 0x4152fda9, - 0x5f15adac, 0x5bd4b01b, 0x569796c2, 0x52568b75, - 0x6a1936c8, 0x6ed82b7f, 0x639b0da6, 0x675a1011, - 0x791d4014, 0x7ddc5da3, 0x709f7b7a, 0x745e66cd, - 0x9823b6e0, 0x9ce2ab57, 0x91a18d8e, 0x95609039, - 0x8b27c03c, 0x8fe6dd8b, 0x82a5fb52, 0x8664e6e5, - 0xbe2b5b58, 0xbaea46ef, 0xb7a96036, 0xb3687d81, - 0xad2f2d84, 0xa9ee3033, 0xa4ad16ea, 0xa06c0b5d, - 0xd4326d90, 0xd0f37027, 0xddb056fe, 0xd9714b49, - 0xc7361b4c, 0xc3f706fb, 0xceb42022, 0xca753d95, - 0xf23a8028, 0xf6fb9d9f, 0xfbb8bb46, 0xff79a6f1, - 0xe13ef6f4, 0xe5ffeb43, 0xe8bccd9a, 0xec7dd02d, - 0x34867077, 0x30476dc0, 0x3d044b19, 0x39c556ae, - 0x278206ab, 0x23431b1c, 0x2e003dc5, 0x2ac12072, - 0x128e9dcf, 0x164f8078, 0x1b0ca6a1, 0x1fcdbb16, - 0x018aeb13, 0x054bf6a4, 0x0808d07d, 0x0cc9cdca, - 0x7897ab07, 0x7c56b6b0, 0x71159069, 0x75d48dde, - 0x6b93dddb, 0x6f52c06c, 0x6211e6b5, 0x66d0fb02, - 0x5e9f46bf, 0x5a5e5b08, 0x571d7dd1, 0x53dc6066, - 0x4d9b3063, 0x495a2dd4, 0x44190b0d, 0x40d816ba, - 0xaca5c697, 0xa864db20, 0xa527fdf9, 0xa1e6e04e, - 0xbfa1b04b, 0xbb60adfc, 0xb6238b25, 0xb2e29692, - 0x8aad2b2f, 0x8e6c3698, 0x832f1041, 0x87ee0df6, - 0x99a95df3, 0x9d684044, 0x902b669d, 0x94ea7b2a, - 0xe0b41de7, 0xe4750050, 0xe9362689, 0xedf73b3e, - 0xf3b06b3b, 0xf771768c, 0xfa325055, 0xfef34de2, - 0xc6bcf05f, 0xc27dede8, 0xcf3ecb31, 0xcbffd686, - 0xd5b88683, 0xd1799b34, 0xdc3abded, 0xd8fba05a, - 0x690ce0ee, 0x6dcdfd59, 0x608edb80, 0x644fc637, - 0x7a089632, 0x7ec98b85, 0x738aad5c, 0x774bb0eb, - 0x4f040d56, 0x4bc510e1, 0x46863638, 0x42472b8f, - 0x5c007b8a, 0x58c1663d, 0x558240e4, 0x51435d53, - 0x251d3b9e, 0x21dc2629, 0x2c9f00f0, 0x285e1d47, - 0x36194d42, 0x32d850f5, 0x3f9b762c, 0x3b5a6b9b, - 0x0315d626, 0x07d4cb91, 0x0a97ed48, 0x0e56f0ff, - 0x1011a0fa, 0x14d0bd4d, 0x19939b94, 0x1d528623, - 0xf12f560e, 0xf5ee4bb9, 0xf8ad6d60, 0xfc6c70d7, - 0xe22b20d2, 0xe6ea3d65, 0xeba91bbc, 0xef68060b, - 0xd727bbb6, 0xd3e6a601, 0xdea580d8, 0xda649d6f, - 0xc423cd6a, 0xc0e2d0dd, 0xcda1f604, 0xc960ebb3, - 0xbd3e8d7e, 0xb9ff90c9, 0xb4bcb610, 0xb07daba7, - 0xae3afba2, 0xaafbe615, 0xa7b8c0cc, 0xa379dd7b, - 0x9b3660c6, 0x9ff77d71, 0x92b45ba8, 0x9675461f, - 0x8832161a, 0x8cf30bad, 0x81b02d74, 0x857130c3, - 0x5d8a9099, 0x594b8d2e, 0x5408abf7, 0x50c9b640, - 0x4e8ee645, 0x4a4ffbf2, 0x470cdd2b, 0x43cdc09c, - 0x7b827d21, 0x7f436096, 0x7200464f, 0x76c15bf8, - 0x68860bfd, 0x6c47164a, 0x61043093, 0x65c52d24, - 0x119b4be9, 0x155a565e, 0x18197087, 0x1cd86d30, - 0x029f3d35, 0x065e2082, 0x0b1d065b, 0x0fdc1bec, - 0x3793a651, 0x3352bbe6, 0x3e119d3f, 0x3ad08088, - 0x2497d08d, 0x2056cd3a, 0x2d15ebe3, 0x29d4f654, - 0xc5a92679, 0xc1683bce, 0xcc2b1d17, 0xc8ea00a0, - 0xd6ad50a5, 0xd26c4d12, 0xdf2f6bcb, 0xdbee767c, - 0xe3a1cbc1, 0xe760d676, 0xea23f0af, 0xeee2ed18, - 0xf0a5bd1d, 0xf464a0aa, 0xf9278673, 0xfde69bc4, - 0x89b8fd09, 0x8d79e0be, 0x803ac667, 0x84fbdbd0, - 0x9abc8bd5, 0x9e7d9662, 0x933eb0bb, 0x97ffad0c, - 0xafb010b1, 0xab710d06, 0xa6322bdf, 0xa2f33668, - 0xbcb4666d, 0xb8757bda, 0xb5365d03, 0xb1f740b4, -]; - -fn ogg_checksum(data: &[u8]) -> u32 { - let mut crc: u32 = 0; - for &b in data { - crc = (crc << 8) ^ CRC_LOOKUP[((crc >> 24) as u8 ^ b) as usize]; - } - crc -} - -// --------------------------------------------------------------------------- -// Helper: read LE integers from byte slices -// --------------------------------------------------------------------------- - -fn read_u16_le(data: &[u8], off: usize) -> u16 { - u16::from_le_bytes([data[off], data[off + 1]]) -} - -fn read_u32_le(data: &[u8], off: usize) -> u32 { - u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) -} - -fn write_u16_le(data: &mut [u8], off: usize, val: u16) { - let bytes = val.to_le_bytes(); - data[off] = bytes[0]; - data[off + 1] = bytes[1]; -} - -fn write_u32_le(data: &mut [u8], off: usize, val: u32) { - let bytes = val.to_le_bytes(); - data[off] = bytes[0]; - data[off + 1] = bytes[1]; - data[off + 2] = bytes[2]; - data[off + 3] = bytes[3]; -} - -// --------------------------------------------------------------------------- -// BitReader — reads bits LSB-first from a byte stream -// --------------------------------------------------------------------------- - -struct BitReader<'a> { - data: &'a [u8], - byte_offset: usize, - bit_buffer: u8, - bits_left: u8, - total_bits_read: usize, -} - -impl<'a> BitReader<'a> { - fn new(data: &'a [u8], initial_offset: usize) -> Self { - Self { - data, - byte_offset: initial_offset, - bit_buffer: 0, - bits_left: 0, - total_bits_read: 0, - } - } - - fn get_bit(&mut self) -> Result { - if self.bits_left == 0 { - let pos = self.byte_offset + self.total_bits_read / 8; - if pos >= self.data.len() { - return Err("BitReader: out of bits".into()); - } - self.bit_buffer = self.data[pos]; - self.bits_left = 8; - } - self.total_bits_read += 1; - self.bits_left -= 1; - Ok(if (self.bit_buffer & (0x80 >> self.bits_left)) != 0 { - 1 - } else { - 0 - }) - } - - fn read_bits(&mut self, count: u32) -> Result { - let mut value: u32 = 0; - for i in 0..count { - if self.get_bit()? != 0 { - value |= 1 << i; - } - } - Ok(value) - } - - fn total_bits_read(&self) -> usize { - self.total_bits_read - } -} - -// --------------------------------------------------------------------------- -// BitOggWriter — writes bits and constructs OGG pages -// --------------------------------------------------------------------------- - -struct BitOggWriter { - output: Vec, - bit_buffer: u8, - bits_stored: u8, - payload_bytes: usize, - first: bool, - continued: bool, - granule: u32, - seqno: u32, - page_buffer: Vec, -} - -impl BitOggWriter { - fn new() -> Self { - Self { - output: Vec::new(), - bit_buffer: 0, - bits_stored: 0, - payload_bytes: 0, - first: true, - continued: false, - granule: 0, - seqno: 0, - page_buffer: vec![0u8; 27 + 255 + 255 * 255], - } - } - - fn put_bit(&mut self, bit: bool) { - if bit { - self.bit_buffer |= 1 << self.bits_stored; - } - self.bits_stored += 1; - if self.bits_stored == 8 { - self.flush_bits(); - } - } - - fn write_bits(&mut self, value: u32, count: u32) { - for i in 0..count { - self.put_bit((value & (1 << i)) != 0); - } - } - - fn set_granule(&mut self, g: u32) { - self.granule = g; - } - - fn flush_bits(&mut self) { - if self.bits_stored != 0 { - if self.payload_bytes == 255 * 255 { - self.flush_page(true, false); - } - self.page_buffer[27 + 255 + self.payload_bytes] = self.bit_buffer; - self.payload_bytes += 1; - self.bits_stored = 0; - self.bit_buffer = 0; - } - } - - fn flush_page(&mut self, next_continued: bool, last: bool) { - if self.payload_bytes != 255 * 255 { - self.flush_bits(); - } - - if self.payload_bytes == 0 { - return; - } - - let segment_size: usize = 255; - let mut segments = self.payload_bytes.div_ceil(segment_size); - if segments == 256 { - segments = 255; - } - - for i in 0..self.payload_bytes { - self.page_buffer[27 + segments + i] = self.page_buffer[27 + 255 + i]; - } - - self.page_buffer[0] = b'O'; - self.page_buffer[1] = b'g'; - self.page_buffer[2] = b'g'; - self.page_buffer[3] = b'S'; - self.page_buffer[4] = 0; // stream_structure_version - self.page_buffer[5] = (if self.continued { 1 } else { 0 }) - | (if self.first { 2 } else { 0 }) - | (if last { 4 } else { 0 }); - - // Granule position (64 bits) - write_u32_le(&mut self.page_buffer, 6, self.granule); - if self.granule == 0xFFFFFFFF { - write_u32_le(&mut self.page_buffer, 10, 0xFFFFFFFF); - } else { - write_u32_le(&mut self.page_buffer, 10, 0); - } - - write_u32_le(&mut self.page_buffer, 14, 1); // stream serial number - write_u32_le(&mut self.page_buffer, 18, self.seqno); // page sequence - write_u32_le(&mut self.page_buffer, 22, 0); // checksum placeholder - self.page_buffer[26] = segments as u8; // segment count - - // Lacing values - let mut bytes_left = self.payload_bytes; - for i in 0..segments { - if bytes_left >= segment_size { - bytes_left -= segment_size; - self.page_buffer[27 + i] = segment_size as u8; - } else { - self.page_buffer[27 + i] = bytes_left as u8; - } - } - - let page_size = 27 + segments + self.payload_bytes; - let crc = ogg_checksum(&self.page_buffer[..page_size]); - write_u32_le(&mut self.page_buffer, 22, crc); - - self.output - .extend_from_slice(&self.page_buffer[..page_size]); - - self.seqno += 1; - self.first = false; - self.continued = next_continued; - self.payload_bytes = 0; - } - - fn get_output(mut self) -> Vec { - self.flush_page(false, false); - self.output - } -} - -// --------------------------------------------------------------------------- -// Codebook library -// --------------------------------------------------------------------------- - -struct CodebookLibrary { - codebook_data: Vec, - codebook_offsets: Vec, - codebook_count: usize, -} - -impl CodebookLibrary { - fn load(data: &[u8]) -> Result { - if data.len() < 8 { - return Err("Codebook data too small".into()); - } - - let len = data.len(); - let offset_offset = read_u32_le(data, len - 4) as usize; - - if offset_offset >= len { - return Err("Invalid codebook offset table position".into()); - } - - let num_offsets = (len - offset_offset) / 4; - if num_offsets == 0 { - return Err("No codebook offsets found".into()); - } - let codebook_count = num_offsets - 1; - - let codebook_data = data[..offset_offset].to_vec(); - let mut codebook_offsets = Vec::with_capacity(num_offsets); - for i in 0..num_offsets { - let pos = offset_offset + i * 4; - codebook_offsets.push(read_u32_le(data, pos)); - } - - Ok(Self { - codebook_data, - codebook_offsets, - codebook_count, - }) - } - - fn get_codebook(&self, id: usize) -> Result<&[u8], String> { - if id >= self.codebook_count { - return Err(format!( - "Invalid codebook id: {id} (max {})", - self.codebook_count - 1 - )); - } - let start = self.codebook_offsets[id] as usize; - let end = self.codebook_offsets[id + 1] as usize; - Ok(&self.codebook_data[start..end]) - } - - fn rebuild_from_id(&self, id: usize, bos: &mut BitOggWriter) -> Result<(), String> { - let cb = self.get_codebook(id)?; - let mut bis = BitReader::new(cb, 0); - Self::rebuild(&mut bis, bos) - } - - fn rebuild(bis: &mut BitReader, bos: &mut BitOggWriter) -> Result<(), String> { - let dimensions = bis.read_bits(4)?; - let entries = bis.read_bits(14)?; - - bos.write_bits(0x564342, 24); - bos.write_bits(dimensions, 16); - bos.write_bits(entries, 24); - - let ordered = bis.read_bits(1)?; - bos.write_bits(ordered, 1); - - if ordered != 0 { - let initial_length = bis.read_bits(5)?; - bos.write_bits(initial_length, 5); - - let mut current_entry: u32 = 0; - while current_entry < entries { - let num_bits = ilog(entries - current_entry); - let number = bis.read_bits(num_bits)?; - bos.write_bits(number, num_bits); - current_entry += number; - } - if current_entry > entries { - return Err("current_entry out of range".into()); - } - } else { - let codeword_length_length = bis.read_bits(3)?; - let sparse = bis.read_bits(1)?; - - if codeword_length_length == 0 || codeword_length_length > 5 { - return Err("nonsense codeword length".into()); - } - - bos.write_bits(sparse, 1); - - for _ in 0..entries { - let mut present_bool = true; - if sparse != 0 { - let present = bis.read_bits(1)?; - bos.write_bits(present, 1); - present_bool = present != 0; - } - if present_bool { - let codeword_length = bis.read_bits(codeword_length_length)?; - bos.write_bits(codeword_length, 5); - } - } - } - - let lookup_type = bis.read_bits(1)?; - bos.write_bits(lookup_type, 4); - - if lookup_type == 1 { - let min = bis.read_bits(32)?; - let max = bis.read_bits(32)?; - let value_length = bis.read_bits(4)?; - let sequence_flag = bis.read_bits(1)?; - - bos.write_bits(min, 32); - bos.write_bits(max, 32); - bos.write_bits(value_length, 4); - bos.write_bits(sequence_flag, 1); - - let quantvals = book_maptype1_quantvals(entries, dimensions); - for _ in 0..quantvals { - let val = bis.read_bits(value_length + 1)?; - bos.write_bits(val, value_length + 1); - } - } else if lookup_type != 0 { - return Err("Invalid lookup type".into()); - } - - Ok(()) - } - - fn copy_codebook(bis: &mut BitReader, bos: &mut BitOggWriter) -> Result<(), String> { - let id = bis.read_bits(24)?; - let dimensions = bis.read_bits(16)?; - let entries = bis.read_bits(24)?; - - if id != 0x564342 { - return Err("Invalid codebook identifier".into()); - } - - bos.write_bits(id, 24); - bos.write_bits(dimensions, 16); - bos.write_bits(entries, 24); - - let ordered = bis.read_bits(1)?; - bos.write_bits(ordered, 1); - - if ordered != 0 { - let initial_length = bis.read_bits(5)?; - bos.write_bits(initial_length, 5); - - let mut current_entry: u32 = 0; - while current_entry < entries { - let num_bits = ilog(entries - current_entry); - let number = bis.read_bits(num_bits)?; - bos.write_bits(number, num_bits); - current_entry += number; - } - } else { - let sparse = bis.read_bits(1)?; - bos.write_bits(sparse, 1); - - for _ in 0..entries { - let mut present_bool = true; - if sparse != 0 { - let present = bis.read_bits(1)?; - bos.write_bits(present, 1); - present_bool = present != 0; - } - if present_bool { - let codeword_length = bis.read_bits(5)?; - bos.write_bits(codeword_length, 5); - } - } - } - - let lookup_type = bis.read_bits(4)?; - bos.write_bits(lookup_type, 4); - - if lookup_type == 1 { - let min = bis.read_bits(32)?; - let max = bis.read_bits(32)?; - let value_length = bis.read_bits(4)?; - let sequence_flag = bis.read_bits(1)?; - - bos.write_bits(min, 32); - bos.write_bits(max, 32); - bos.write_bits(value_length, 4); - bos.write_bits(sequence_flag, 1); - - let quantvals = book_maptype1_quantvals(entries, dimensions); - for _ in 0..quantvals { - let val = bis.read_bits(value_length + 1)?; - bos.write_bits(val, value_length + 1); - } - } else if lookup_type == 2 { - return Err("Didn't expect lookup type 2".into()); - } else if lookup_type != 0 { - return Err("Invalid lookup type".into()); - } - - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Helper math -// --------------------------------------------------------------------------- - -fn ilog(v: u32) -> u32 { - if v == 0 { - return 0; - } - 32 - v.leading_zeros() -} - -fn book_maptype1_quantvals(entries: u32, dimensions: u32) -> u32 { - if dimensions == 0 || entries == 0 { - return 0; - } - let bits = ilog(entries); - let mut vals = entries >> ((bits - 1) * (dimensions - 1) / dimensions); - - loop { - let mut acc: u64 = 1; - let mut acc1: u64 = 1; - for _ in 0..dimensions { - acc *= vals as u64; - acc1 *= (vals + 1) as u64; - } - if acc <= entries as u64 && acc1 > entries as u64 { - return vals; - } - if acc > entries as u64 { - vals -= 1; - } else { - vals += 1; - } - } -} - -// --------------------------------------------------------------------------- -// Packet header readers -// --------------------------------------------------------------------------- - -struct PacketHeader { - header_size: usize, - size: usize, - granule: u32, -} - -impl PacketHeader { - /// Modern 2 or 6 byte header - fn read(data: &[u8], offset: usize, no_granule: bool) -> Result { - if offset + 2 > data.len() { - return Err("Packet header truncated".into()); - } - let size = read_u16_le(data, offset) as usize; - let (header_size, granule) = if no_granule { - (2, 0) - } else { - if offset + 6 > data.len() { - return Err("Packet header truncated (6-byte)".into()); - } - (6, read_u32_le(data, offset + 2)) - }; - Ok(Self { - header_size, - size, - granule, - }) - } - - /// Old 8-byte header - fn read_old(data: &[u8], offset: usize) -> Result { - if offset + 8 > data.len() { - return Err("Old packet header truncated".into()); - } - Ok(Self { - header_size: 8, - size: read_u32_le(data, offset) as usize, - granule: read_u32_le(data, offset + 4), - }) - } - - fn payload_offset(&self, base: usize) -> usize { - base + self.header_size - } - - fn next_offset(&self, base: usize) -> usize { - base + self.header_size + self.size - } -} - -// --------------------------------------------------------------------------- -// WwiseRiffVorbis — main converter -// --------------------------------------------------------------------------- - -struct WwiseRiffVorbis<'a> { - data: &'a [u8], - is_wav: bool, - - data_offset: usize, - data_size: usize, - - channels: u32, - sample_rate: u32, - avg_bytes_per_second: u32, - block_align: u16, - bits_per_sample: u16, - - setup_packet_offset: u32, - first_audio_packet_offset: u32, - blocksize_0_pow: u8, - blocksize_1_pow: u8, - - header_triad_present: bool, - old_packet_headers: bool, - no_granule: bool, - mod_packets: bool, - - loop_count: u32, - loop_start: u32, - loop_end: u32, - - codebook_lib: &'a CodebookLibrary, -} - -impl<'a> WwiseRiffVorbis<'a> { - fn parse(data: &'a [u8], codebook_lib: &'a CodebookLibrary) -> Result { - if data.len() < 12 { - return Err("Data too small for RIFF".into()); - } - - let magic = &data[0..4]; - if magic != b"RIFF" && magic != b"RIFX" { - return Err("Missing RIFF header".into()); - } - if magic == b"RIFX" { - return Err("RIFX (big-endian) not supported".into()); - } - - let riff_size = read_u32_le(data, 4) as usize + 8; - if riff_size > data.len() { - return Err("RIFF truncated".into()); - } - - if &data[8..12] != b"WAVE" { - return Err("Missing WAVE header".into()); - } - - let mut fmt_offset = 0usize; - let mut fmt_size = 0usize; - let mut smpl_offset = 0usize; - let mut vorb_offset = 0usize; - let mut vorb_size: i32 = -1; - let mut data_offset = 0usize; - let mut data_size = 0usize; - - let mut chunk_offset = 12usize; - while chunk_offset + 8 <= riff_size { - let chunk_type = &data[chunk_offset..chunk_offset + 4]; - let chunk_len = read_u32_le(data, chunk_offset + 4) as usize; - - match chunk_type { - b"fmt " => { - fmt_offset = chunk_offset + 8; - fmt_size = chunk_len; - } - b"smpl" => { - smpl_offset = chunk_offset + 8; - } - b"vorb" => { - vorb_offset = chunk_offset + 8; - vorb_size = chunk_len as i32; - } - b"data" => { - data_offset = chunk_offset + 8; - data_size = chunk_len; - } - _ => {} - } - - chunk_offset += 8 + chunk_len; - } - - if fmt_offset == 0 || data_offset == 0 { - return Err("Expected fmt, data chunks".into()); - } - - let mut is_wav = false; - - if vorb_offset == 0 { - if fmt_size == 0x18 { - is_wav = true; - } else if fmt_size == 0x42 { - vorb_offset = fmt_offset + 0x18; - vorb_size = -1; - } else { - return Err("Expected fmt_size of 0x18 or 0x42 if vorb section missing".into()); - } - } - - let codec_id = read_u16_le(data, fmt_offset); - if is_wav { - if codec_id != 0xFFFE { - return Err(format!("Bad codec id for WAV: 0x{codec_id:04X}")); - } - } else if codec_id != 0xFFFF { - return Err(format!("Bad codec id: 0x{codec_id:04X}")); - } - - let channels = read_u16_le(data, fmt_offset + 2) as u32; - let sample_rate = read_u32_le(data, fmt_offset + 4); - let avg_bytes_per_second = read_u32_le(data, fmt_offset + 8); - let block_align = read_u16_le(data, fmt_offset + 12); - let bits_per_sample = read_u16_le(data, fmt_offset + 14); - - if is_wav { - return Ok(Self { - data, - is_wav: true, - data_offset, - data_size, - channels, - sample_rate, - avg_bytes_per_second, - block_align, - bits_per_sample, - setup_packet_offset: 0, - first_audio_packet_offset: 0, - blocksize_0_pow: 0, - blocksize_1_pow: 0, - header_triad_present: false, - old_packet_headers: false, - no_granule: false, - mod_packets: false, - loop_count: 0, - loop_start: 0, - loop_end: 0, - codebook_lib, - }); - } - - let mut loop_count = 0u32; - let mut loop_start = 0u32; - let mut loop_end = 0u32; - if smpl_offset != 0 { - loop_count = read_u32_le(data, smpl_offset + 0x1C); - if loop_count == 1 { - loop_start = read_u32_le(data, smpl_offset + 0x2C); - loop_end = read_u32_le(data, smpl_offset + 0x30); - } - } - - let valid_vorb_sizes: &[i32] = &[-1, 0x28, 0x2A, 0x2C, 0x32, 0x34]; - if !valid_vorb_sizes.contains(&vorb_size) { - return Err(format!("Bad vorb size: {vorb_size}")); - } - - let sample_count = read_u32_le(data, vorb_offset); - - let mut no_granule = false; - let mut mod_packets = false; - let mut header_triad_present = false; - let mut old_packet_headers = false; - let mut blocksize_0_pow: u8 = 0; - let mut blocksize_1_pow: u8 = 0; - - let file_pos; - if vorb_size == -1 || vorb_size == 0x2A { - no_granule = true; - let mod_signal = read_u32_le(data, vorb_offset + 0x4); - if mod_signal != 0x4A && mod_signal != 0x4B && mod_signal != 0x69 && mod_signal != 0x70 - { - mod_packets = true; - } - file_pos = vorb_offset + 0x10; - } else { - file_pos = vorb_offset + 0x18; - } - - let setup_packet_offset = read_u32_le(data, file_pos); - let first_audio_packet_offset = read_u32_le(data, file_pos + 4); - - if vorb_size == 0x28 || vorb_size == 0x2C { - header_triad_present = true; - old_packet_headers = true; - } else { - let bp_pos = if vorb_size == -1 || vorb_size == 0x2A { - vorb_offset + 0x24 - } else { - vorb_offset + 0x2C - }; - // uid skipped - blocksize_0_pow = data[bp_pos + 4]; - blocksize_1_pow = data[bp_pos + 5]; - } - - if loop_count != 0 { - if loop_end == 0 { - loop_end = sample_count; - } else { - loop_end += 1; - } - } - - Ok(Self { - data, - is_wav: false, - data_offset, - data_size, - channels, - sample_rate, - avg_bytes_per_second, - block_align, - bits_per_sample, - setup_packet_offset, - first_audio_packet_offset, - blocksize_0_pow, - blocksize_1_pow, - header_triad_present, - old_packet_headers, - no_granule, - mod_packets, - loop_count, - loop_start, - loop_end, - codebook_lib, - }) - } - - fn generate_ogg(&self) -> Result, String> { - if self.is_wav { - return Ok(self.generate_wav()); - } - - let mut os = BitOggWriter::new(); - let mut mode_blockflag: Option> = None; - let mut mode_bits: u32 = 0; - let mut prev_blockflag = false; - - if self.header_triad_present { - self.generate_ogg_header_with_triad(&mut os)?; - } else { - let result = self.generate_ogg_header(&mut os)?; - mode_blockflag = Some(result.0); - mode_bits = result.1; - } - - let mut offset = self.data_offset + self.first_audio_packet_offset as usize; - let data_end = self.data_offset + self.data_size; - - while offset < data_end { - let packet = if self.old_packet_headers { - PacketHeader::read_old(self.data, offset)? - } else { - PacketHeader::read(self.data, offset, self.no_granule)? - }; - - let size = packet.size; - let payload_offset = packet.payload_offset(offset); - let granule = packet.granule; - let next_offset = packet.next_offset(offset); - - if offset + packet.header_size > data_end { - return Err("Page header truncated".into()); - } - - if granule == 0xFFFFFFFF { - os.set_granule(1); - } else { - os.set_granule(granule); - } - - if self.mod_packets { - let mbf = mode_blockflag - .as_ref() - .ok_or("Didn't load mode_blockflag")?; - - // 1-bit packet type (0 = audio) - os.write_bits(0, 1); - - let mut ss = BitReader::new(self.data, payload_offset); - - let mode_number = ss.read_bits(mode_bits)?; - os.write_bits(mode_number, mode_bits); - - let remainder = ss.read_bits(8 - mode_bits)?; - - if mbf.get(mode_number as usize).copied().unwrap_or(false) { - // Long window — peek at next frame - let mut next_blockflag = false; - if next_offset + packet.header_size <= data_end { - let next_packet = - PacketHeader::read(self.data, next_offset, self.no_granule)?; - if next_packet.size > 0 { - let mut next_ss = - BitReader::new(self.data, next_packet.payload_offset(next_offset)); - let next_mode = next_ss.read_bits(mode_bits)?; - next_blockflag = mbf.get(next_mode as usize).copied().unwrap_or(false); - } - } - - os.write_bits(if prev_blockflag { 1 } else { 0 }, 1); - os.write_bits(if next_blockflag { 1 } else { 0 }, 1); - } - - prev_blockflag = mbf.get(mode_number as usize).copied().unwrap_or(false); - - os.write_bits(remainder, 8 - mode_bits); - } else { - os.write_bits(self.data[payload_offset] as u32, 8); - } - - for i in 1..size { - os.write_bits(self.data[payload_offset + i] as u32, 8); - } - - offset = next_offset; - os.flush_page(false, offset >= data_end); - } - - Ok(os.get_output()) - } - - fn generate_wav(&self) -> Vec { - let total_size = 44 + self.data_size; - let mut output = vec![0u8; total_size]; - - output[0..4].copy_from_slice(b"RIFF"); - write_u32_le(&mut output, 4, (total_size - 8) as u32); - output[8..12].copy_from_slice(b"WAVE"); - - output[12..16].copy_from_slice(b"fmt "); - write_u32_le(&mut output, 16, 16); - write_u16_le(&mut output, 20, 1); // PCM - write_u16_le(&mut output, 22, self.channels as u16); - write_u32_le(&mut output, 24, self.sample_rate); - write_u32_le(&mut output, 28, self.avg_bytes_per_second); - write_u16_le(&mut output, 32, self.block_align); - write_u16_le(&mut output, 34, self.bits_per_sample); - - output[36..40].copy_from_slice(b"data"); - write_u32_le(&mut output, 40, self.data_size as u32); - - let src = &self.data[self.data_offset..self.data_offset + self.data_size]; - output[44..44 + self.data_size].copy_from_slice(src); - - output - } - - fn generate_ogg_header(&self, os: &mut BitOggWriter) -> Result<(Vec, u32), String> { - self.write_vorbis_packet_header(os, 1); - - os.write_bits(0, 32); // version - os.write_bits(self.channels, 8); - os.write_bits(self.sample_rate, 32); - os.write_bits(0, 32); // bitrate max - os.write_bits(self.avg_bytes_per_second * 8, 32); // bitrate nominal - os.write_bits(0, 32); // bitrate min - os.write_bits(self.blocksize_0_pow as u32, 4); - os.write_bits(self.blocksize_1_pow as u32, 4); - os.write_bits(1, 1); // framing - - os.flush_page(false, false); - - self.write_vorbis_packet_header(os, 3); - - let vendor = b"converted from Audiokinetic Wwise by ww2ogg (Rust)"; - os.write_bits(vendor.len() as u32, 32); - for &b in vendor { - os.write_bits(b as u32, 8); - } - - if self.loop_count == 0 { - os.write_bits(0, 32); // no user comments - } else { - os.write_bits(2, 32); - - let ls = format!("LoopStart={}", self.loop_start); - os.write_bits(ls.len() as u32, 32); - for b in ls.bytes() { - os.write_bits(b as u32, 8); - } - - let le = format!("LoopEnd={}", self.loop_end); - os.write_bits(le.len() as u32, 32); - for b in le.bytes() { - os.write_bits(b as u32, 8); - } - } - - os.write_bits(1, 1); // framing - os.flush_page(false, false); - - self.write_vorbis_packet_header(os, 5); - - let setup_packet = PacketHeader::read( - self.data, - self.data_offset + self.setup_packet_offset as usize, - self.no_granule, - )?; - - if setup_packet.granule != 0 { - return Err("Setup packet granule != 0".into()); - } - - let setup_payload = - setup_packet.payload_offset(self.data_offset + self.setup_packet_offset as usize); - let mut ss = BitReader::new(self.data, setup_payload); - - let codebook_count_less1 = ss.read_bits(8)?; - let codebook_count = codebook_count_less1 + 1; - os.write_bits(codebook_count_less1, 8); - - for _ in 0..codebook_count { - let codebook_id = ss.read_bits(10)?; - self.codebook_lib - .rebuild_from_id(codebook_id as usize, os)?; - } - - // Time domain transforms - os.write_bits(0, 6); // count - 1 - os.write_bits(0, 16); // dummy - - let result = self.rebuild_setup(&mut ss, os, codebook_count)?; - - os.write_bits(1, 1); // framing - os.flush_page(false, false); - - Ok(result) - } - - fn rebuild_setup( - &self, - ss: &mut BitReader, - os: &mut BitOggWriter, - codebook_count: u32, - ) -> Result<(Vec, u32), String> { - let floor_count_less1 = ss.read_bits(6)?; - let floor_count = floor_count_less1 + 1; - os.write_bits(floor_count_less1, 6); - - for _ in 0..floor_count { - os.write_bits(1, 16); // floor type 1 - - let floor1_partitions = ss.read_bits(5)?; - os.write_bits(floor1_partitions, 5); - - let mut partition_class_list = Vec::new(); - let mut maximum_class: u32 = 0; - - for _ in 0..floor1_partitions { - let pc = ss.read_bits(4)?; - os.write_bits(pc, 4); - partition_class_list.push(pc); - if pc > maximum_class { - maximum_class = pc; - } - } - - let mut class_dimensions_list = Vec::new(); - - for _ in 0..=maximum_class { - let cd_less1 = ss.read_bits(3)?; - os.write_bits(cd_less1, 3); - class_dimensions_list.push(cd_less1 + 1); - - let class_subclasses = ss.read_bits(2)?; - os.write_bits(class_subclasses, 2); - - if class_subclasses != 0 { - let masterbook = ss.read_bits(8)?; - os.write_bits(masterbook, 8); - if masterbook >= codebook_count { - return Err("Invalid floor1 masterbook".into()); - } - } - - for _ in 0..(1u32 << class_subclasses) { - let sb = ss.read_bits(8)?; - os.write_bits(sb, 8); - } - } - - let multiplier_less1 = ss.read_bits(2)?; - os.write_bits(multiplier_less1, 2); - - let rangebits = ss.read_bits(4)?; - os.write_bits(rangebits, 4); - - for &pc in &partition_class_list { - let ccn = pc as usize; - for _ in 0..class_dimensions_list[ccn] { - let x = ss.read_bits(rangebits)?; - os.write_bits(x, rangebits); - } - } - } - - let residue_count_less1 = ss.read_bits(6)?; - let residue_count = residue_count_less1 + 1; - os.write_bits(residue_count_less1, 6); - - for _ in 0..residue_count { - let residue_type = ss.read_bits(2)?; - os.write_bits(residue_type, 16); - - if residue_type > 2 { - return Err("Invalid residue type".into()); - } - - let begin = ss.read_bits(24)?; - let end = ss.read_bits(24)?; - let partition_size_less1 = ss.read_bits(24)?; - let classifications_less1 = ss.read_bits(6)?; - let classbook = ss.read_bits(8)?; - let classifications = classifications_less1 + 1; - - os.write_bits(begin, 24); - os.write_bits(end, 24); - os.write_bits(partition_size_less1, 24); - os.write_bits(classifications_less1, 6); - os.write_bits(classbook, 8); - - if classbook >= codebook_count { - return Err("Invalid residue classbook".into()); - } - - let mut cascade = Vec::new(); - for _ in 0..classifications { - let low_bits = ss.read_bits(3)?; - os.write_bits(low_bits, 3); - - let bitflag = ss.read_bits(1)?; - os.write_bits(bitflag, 1); - - let mut high_bits = 0u32; - if bitflag != 0 { - high_bits = ss.read_bits(5)?; - os.write_bits(high_bits, 5); - } - cascade.push(high_bits * 8 + low_bits); - } - - for &cas in &cascade { - for k in 0..8u32 { - if cas & (1 << k) != 0 { - let book = ss.read_bits(8)?; - os.write_bits(book, 8); - if book >= codebook_count { - return Err("Invalid residue book".into()); - } - } - } - } - } - - let mapping_count_less1 = ss.read_bits(6)?; - let mapping_count = mapping_count_less1 + 1; - os.write_bits(mapping_count_less1, 6); - - for _ in 0..mapping_count { - os.write_bits(0, 16); // mapping type 0 - - let submaps_flag = ss.read_bits(1)?; - os.write_bits(submaps_flag, 1); - - let mut submaps = 1u32; - if submaps_flag != 0 { - let sl = ss.read_bits(4)?; - submaps = sl + 1; - os.write_bits(sl, 4); - } - - let square_polar = ss.read_bits(1)?; - os.write_bits(square_polar, 1); - - if square_polar != 0 { - let coupling_less1 = ss.read_bits(8)?; - let coupling_steps = coupling_less1 + 1; - os.write_bits(coupling_less1, 8); - - let channel_bits = ilog(self.channels - 1); - for _ in 0..coupling_steps { - let magnitude = ss.read_bits(channel_bits)?; - let angle = ss.read_bits(channel_bits)?; - os.write_bits(magnitude, channel_bits); - os.write_bits(angle, channel_bits); - } - } - - let reserved = ss.read_bits(2)?; - os.write_bits(reserved, 2); - if reserved != 0 { - return Err("Mapping reserved field nonzero".into()); - } - - if submaps > 1 { - for _ in 0..self.channels { - let mux = ss.read_bits(4)?; - os.write_bits(mux, 4); - } - } - - for _ in 0..submaps { - let time_config = ss.read_bits(8)?; - os.write_bits(time_config, 8); - - let floor_number = ss.read_bits(8)?; - os.write_bits(floor_number, 8); - if floor_number >= floor_count { - return Err("Invalid floor mapping".into()); - } - - let residue_number = ss.read_bits(8)?; - os.write_bits(residue_number, 8); - if residue_number >= residue_count { - return Err("Invalid residue mapping".into()); - } - } - } - - let mode_count_less1 = ss.read_bits(6)?; - let mode_count = mode_count_less1 + 1; - os.write_bits(mode_count_less1, 6); - - let mut mode_blockflag = Vec::new(); - let mode_bits = ilog(mode_count - 1); - - for _ in 0..mode_count { - let block_flag = ss.read_bits(1)?; - os.write_bits(block_flag, 1); - mode_blockflag.push(block_flag != 0); - - os.write_bits(0, 16); // window type - os.write_bits(0, 16); // transform type - - let mapping = ss.read_bits(8)?; - os.write_bits(mapping, 8); - if mapping >= mapping_count { - return Err("Invalid mode mapping".into()); - } - } - - Ok((mode_blockflag, mode_bits)) - } - - fn generate_ogg_header_with_triad(&self, os: &mut BitOggWriter) -> Result<(), String> { - let mut offset = self.data_offset + self.setup_packet_offset as usize; - - // Identification packet (old 8-byte header) - let info_packet = PacketHeader::read_old(self.data, offset)?; - if info_packet.granule != 0 { - return Err("Information packet granule != 0".into()); - } - - let info_payload = info_packet.payload_offset(offset); - if self.data[info_payload] != 1 { - return Err("Wrong type for information packet".into()); - } - - for i in 0..info_packet.size { - os.write_bits(self.data[info_payload + i] as u32, 8); - } - os.flush_page(false, false); - - offset = info_packet.next_offset(offset); - - let comment_packet = PacketHeader::read_old(self.data, offset)?; - if comment_packet.granule != 0 { - return Err("Comment packet granule != 0".into()); - } - - let comment_payload = comment_packet.payload_offset(offset); - if self.data[comment_payload] != 3 { - return Err("Wrong type for comment packet".into()); - } - - for i in 0..comment_packet.size { - os.write_bits(self.data[comment_payload + i] as u32, 8); - } - os.flush_page(false, false); - - offset = comment_packet.next_offset(offset); - - let setup_packet = PacketHeader::read_old(self.data, offset)?; - if setup_packet.granule != 0 { - return Err("Setup packet granule != 0".into()); - } - - let setup_payload = setup_packet.payload_offset(offset); - let mut ss = BitReader::new(self.data, setup_payload); - - let setup_type = ss.read_bits(8)?; - if setup_type != 5 { - return Err("Wrong type for setup packet".into()); - } - os.write_bits(setup_type, 8); - - // "vorbis" - for _ in 0..6 { - os.write_bits(ss.read_bits(8)?, 8); - } - - let codebook_count_less1 = ss.read_bits(8)?; - let codebook_count = codebook_count_less1 + 1; - os.write_bits(codebook_count_less1, 8); - - for _ in 0..codebook_count { - CodebookLibrary::copy_codebook(&mut ss, os)?; - } - - while ss.total_bits_read() < setup_packet.size * 8 { - os.write_bits(ss.read_bits(1)?, 1); - } - - os.flush_page(false, false); - Ok(()) - } - - fn write_vorbis_packet_header(&self, os: &mut BitOggWriter, packet_type: u8) { - os.write_bits(packet_type as u32, 8); - for &b in b"vorbis" { - os.write_bits(b as u32, 8); - } - } -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -pub fn decode_wem(wem_data: &[u8]) -> Result { - let codebook_lib = CodebookLibrary::load(CODEBOOK_DATA)?; - let converter = WwiseRiffVorbis::parse(wem_data, &codebook_lib)?; - - if converter.is_wav { - let wav = converter.generate_wav(); - Ok(DecodedAudio { - sample_rate: Some(converter.sample_rate), - data: wav, - format: "wav".into(), - }) - } else { - let ogg = converter.generate_ogg()?; - Ok(DecodedAudio { - sample_rate: Some(converter.sample_rate), - data: ogg, - format: "ogg".into(), - }) - } -} diff --git a/src-tauri/crates/quartz-lib/src/audio/wpk.rs b/src-tauri/crates/quartz-lib/src/audio/wpk.rs deleted file mode 100644 index 3010933e..00000000 --- a/src-tauri/crates/quartz-lib/src/audio/wpk.rs +++ /dev/null @@ -1,309 +0,0 @@ -use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; -use serde::{Deserialize, Serialize}; -use std::io::{Cursor, Read, Write}; - -use super::bnk::{AudioEntry, AudioEntryInfo}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WpkInfo { - pub format: String, - pub version: u32, - pub entry_count: usize, - pub entries: Vec, -} - -#[derive(Debug, Clone)] -struct WpkEntry { - id: u32, - data_offset: u32, - data_length: u32, -} - -#[derive(Debug)] -pub struct WpkFile { - pub version: u32, - entries: Vec, -} - -impl WpkFile { - pub fn parse(data: &[u8]) -> Result { - let mut cursor = Cursor::new(data); - - let mut magic = [0u8; 4]; - cursor - .read_exact(&mut magic) - .map_err(|e| format!("Failed to read WPK magic: {e}"))?; - if &magic != b"r3d2" { - return Err("Not a valid WPK file — missing r3d2 header".into()); - } - - let version = cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK version: {e}"))?; - let file_count = cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK file count: {e}"))?; - - let mut offsets = Vec::with_capacity(file_count as usize); - for _ in 0..file_count { - offsets.push( - cursor - .read_u32::() - .map_err(|e| format!("Failed to read WPK offset: {e}"))?, - ); - } - - let mut entries = Vec::new(); - - for &offset in &offsets { - if offset == 0 { - continue; - } - - cursor.set_position(offset as u64); - - let data_offset = cursor - .read_u32::() - .map_err(|e| format!("Failed to read entry data offset: {e}"))?; - let data_length = cursor - .read_u32::() - .map_err(|e| format!("Failed to read entry data length: {e}"))?; - let filename_length = cursor - .read_u32::() - .map_err(|e| format!("Failed to read filename length: {e}"))?; - - // Read UTF-16LE filename - let mut filename = String::new(); - for _ in 0..filename_length { - let lo = cursor.read_u8().unwrap_or(0); - let _hi = cursor.read_u8().unwrap_or(0); - filename.push(lo as char); - } - - let id: u32 = filename.trim_end_matches(".wem").parse().unwrap_or(0); - - entries.push(WpkEntry { - id, - data_offset, - data_length, - }); - } - - entries.sort_by_key(|e| e.id); - - Ok(WpkFile { version, entries }) - } - - pub fn info(&self) -> WpkInfo { - WpkInfo { - format: "wpk".into(), - version: self.version, - entry_count: self.entries.len(), - entries: self - .entries - .iter() - .map(|e| AudioEntryInfo { - id: e.id, - size: e.data_length, - }) - .collect(), - } - } - - pub fn read_entry_data<'a>(&self, data: &'a [u8], file_id: u32) -> Result<&'a [u8], String> { - let entry = self - .entries - .iter() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Audio entry {file_id} not found in WPK"))?; - - let start = entry.data_offset as usize; - let end = start + entry.data_length as usize; - - if end > data.len() { - return Err(format!( - "Audio entry {file_id} data out of bounds ({}..{} > {})", - start, - end, - data.len() - )); - } - - Ok(&data[start..end]) - } - - pub fn read_all_entries(&self, data: &[u8]) -> Result, String> { - let mut result = Vec::with_capacity(self.entries.len()); - for entry in &self.entries { - let start = entry.data_offset as usize; - let end = start + entry.data_length as usize; - if end > data.len() { - return Err(format!("Audio entry {} data out of bounds", entry.id)); - } - result.push(AudioEntry { - id: entry.id, - data: data[start..end].to_vec(), - }); - } - Ok(result) - } -} - -pub fn parse_wpk_metadata(data: &[u8]) -> Result { - let wpk = WpkFile::parse(data)?; - Ok(wpk.info()) -} - -pub fn read_wpk_entry(data: &[u8], file_id: u32) -> Result, String> { - let wpk = WpkFile::parse(data)?; - Ok(wpk.read_entry_data(data, file_id)?.to_vec()) -} - -// --------------------------------------------------------------------------- -// Writer -// --------------------------------------------------------------------------- - -const WPK_ALIGNMENT: usize = 8; - -fn align_up(val: usize, alignment: usize) -> usize { - let rem = val % alignment; - if rem == 0 { - val - } else { - val + (alignment - rem) - } -} - -pub fn write_wpk(entries: &[AudioEntry]) -> Vec { - let count = entries.len(); - - // Header: "r3d2" (4) + version (4) + file_count (4) + offset_table (count * 4) - let header_size = 12 + count * 4; - let aligned_header = align_up(header_size, WPK_ALIGNMENT); - - struct EntryLayout { - info_pos: usize, - filename: String, - } - let mut layouts = Vec::with_capacity(count); - let mut current_pos = aligned_header; - - for entry in entries { - let filename = format!("{}.wem", entry.id); - let info_size = 12 + filename.len() * 2; // 3 u32s + UTF-16LE chars - let aligned_info = align_up(info_size, WPK_ALIGNMENT); - layouts.push(EntryLayout { - info_pos: current_pos, - filename, - }); - current_pos += aligned_info; - } - - let mut data_offsets = Vec::with_capacity(count); - for entry in entries { - data_offsets.push(current_pos); - current_pos += entry.data.len(); - current_pos = align_up(current_pos, WPK_ALIGNMENT); - } - - let total_size = current_pos; - let mut buf = vec![0u8; total_size]; - let mut w = Cursor::new(&mut buf[..]); - - w.write_all(b"r3d2").unwrap(); - w.write_u32::(1).unwrap(); // version - w.write_u32::(count as u32).unwrap(); - - for layout in &layouts { - w.write_u32::(layout.info_pos as u32).unwrap(); - } - - for (i, layout) in layouts.iter().enumerate() { - w.set_position(layout.info_pos as u64); - w.write_u32::(data_offsets[i] as u32).unwrap(); - w.write_u32::(entries[i].data.len() as u32) - .unwrap(); - w.write_u32::(layout.filename.len() as u32) - .unwrap(); - - for ch in layout.filename.bytes() { - w.write_u8(ch).unwrap(); - w.write_u8(0).unwrap(); - } - } - - for (i, entry) in entries.iter().enumerate() { - buf[data_offsets[i]..data_offsets[i] + entry.data.len()].copy_from_slice(&entry.data); - } - - buf -} - -pub fn replace_wpk_entry(data: &[u8], file_id: u32, new_wem: &[u8]) -> Result, String> { - let wpk = WpkFile::parse(data)?; - let mut entries = wpk.read_all_entries(data)?; - - let entry = entries - .iter_mut() - .find(|e| e.id == file_id) - .ok_or_else(|| format!("Entry {file_id} not found"))?; - entry.data = new_wem.to_vec(); - - Ok(write_wpk(&entries)) -} - -pub fn remove_wpk_entry(data: &[u8], file_id: u32) -> Result, String> { - let wpk = WpkFile::parse(data)?; - let mut entries = wpk.read_all_entries(data)?; - let before = entries.len(); - entries.retain(|e| e.id != file_id); - if entries.len() == before { - return Err(format!("Entry {file_id} not found")); - } - Ok(write_wpk(&entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_test_wpk() -> Vec { - let e1 = AudioEntry { - id: 100, - data: vec![0xAA; 32], - }; - let e2 = AudioEntry { - id: 200, - data: vec![0xBB; 16], - }; - write_wpk(&[e1, e2]) - } - - #[test] - fn roundtrip_write_parse() { - let wpk_data = make_test_wpk(); - let info = parse_wpk_metadata(&wpk_data).unwrap(); - assert_eq!(info.format, "wpk"); - assert_eq!(info.entry_count, 2); - assert_eq!(info.entries[0].id, 100); - assert_eq!(info.entries[0].size, 32); - assert_eq!(info.entries[1].id, 200); - assert_eq!(info.entries[1].size, 16); - } - - #[test] - fn read_entry_data() { - let wpk_data = make_test_wpk(); - let entry_data = read_wpk_entry(&wpk_data, 100).unwrap(); - assert_eq!(entry_data.len(), 32); - assert!(entry_data.iter().all(|&b| b == 0xAA)); - } - - #[test] - fn replace_entry() { - let wpk_data = make_test_wpk(); - let new_wpk = replace_wpk_entry(&wpk_data, 100, &[0xCC; 64]).unwrap(); - let info = parse_wpk_metadata(&new_wpk).unwrap(); - assert_eq!(info.entries[0].size, 64); - } -} diff --git a/src-tauri/crates/quartz-lib/tests/real_banks.rs b/src-tauri/crates/quartz-lib/tests/real_banks.rs new file mode 100644 index 00000000..72259beb --- /dev/null +++ b/src-tauri/crates/quartz-lib/tests/real_banks.rs @@ -0,0 +1,173 @@ +/*! +Exercises the BnkExtract save path against real shipped banks and packages, which is the only +place the things that actually break show up: a header revision that must survive, a HIRC section +that must come back byte for byte, and a package whose entry names and dead slots have to be +reproduced. + +Real game audio is copyrighted and never committed, so every test skips when its fixture is +absent. Drop `.bnk` / `.wpk` samples in the path below to run them. +*/ + +use quartz_lib::audio::bank::{self, Bank}; +use ritoshark::audio::Wem; +use std::path::PathBuf; + +fn fixture(name: &str) -> Option> { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../../RitoShark-Crates/Sample-Files") + .join(name); + std::fs::read(path).ok() +} + +const BANKS: &[&str] = &[ + "aatrox_base_sfx_audio.bnk", + "aatrox_base_sfx_events.bnk", + "bank_v134_audio.bnk", + "bank_v134_bare.bnk", + "bank_v134_events.bnk", + "bank_v145_audio.bnk", + "bank_v145_bare.bnk", + "bank_v145_events.bnk", + "audio_package_4.wpk", + "audio_package_37.wpk", +]; + +#[test] +fn reading_and_writing_a_real_bank_is_byte_exact() { + let mut checked = 0; + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let parsed = Bank::parse(&original).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!( + parsed.to_bytes().unwrap(), + original, + "{name} must re-serialize byte for byte" + ); + checked += 1; + } + eprintln!("round-tripped {checked} real banks"); +} + +#[test] +fn saving_an_untouched_tree_does_not_rewrite_the_file() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let entries = bank::all_entries(&original).unwrap(); + let saved = bank::save_with_entries(&original, &entries).unwrap(); + + assert_eq!( + saved, original, + "{name}: saving without editing anything must not rewrite the file" + ); + } +} + +#[test] +fn a_save_keeps_the_header_and_every_section_we_do_not_model() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + let mut entries = bank::all_entries(&original).unwrap(); + let Some(first) = entries.first_mut() else { + continue; + }; + first.data = bank::to_wem(&first.data.clone(), None).unwrap(); + + let saved = bank::save_with_entries(&original, &entries).unwrap(); + let after = Bank::parse(&saved).unwrap(); + let before = Bank::parse(&original).unwrap(); + + assert_eq!( + after.entries().len(), + before.entries().len(), + "{name}: an edit must not drop entries" + ); + assert_eq!( + after.ids(), + before.ids(), + "{name}: entry ids and their order must survive" + ); + + if let (Bank::Bnk(a), Bank::Bnk(b)) = (&after, &before) { + assert_eq!(a.version(), b.version(), "{name}: header revision"); + assert_eq!(a.bank_id(), b.bank_id(), "{name}: bank id"); + for section in b + .sections + .iter() + .filter(|s| s.tag != *b"DIDX" && s.tag != *b"DATA") + { + let same = a.sections.iter().find(|s| s.tag == section.tag); + assert_eq!( + same.map(|s| &s.data), + Some(§ion.data), + "{name}: section {} must survive verbatim", + String::from_utf8_lossy(§ion.tag) + ); + } + } + } +} + +#[test] +fn every_embedded_payload_decodes() { + for name in BANKS { + let Some(original) = fixture(name) else { + continue; + }; + for entry in bank::all_entries(&original).unwrap() { + bank::decode_wem(&entry.data) + .unwrap_or_else(|e| panic!("{name}: entry {} did not decode: {e}", entry.id)); + } + } +} + +#[test] +fn a_replacement_lands_as_a_playable_wem_in_the_codec_it_replaced() { + let Some(original) = fixture("bank_v145_audio.bnk").or_else(|| fixture("bank_v134_audio.bnk")) + else { + return; + }; + let mut entries = bank::all_entries(&original).unwrap(); + let target = entries[0].id; + let original_payload = entries[0].data.clone(); + + /* What the replace dialog hands the backend once the user picks a non-wem file. */ + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36u32 + 2048).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&44100u32.to_le_bytes()); + wav.extend_from_slice(&88200u32.to_le_bytes()); + wav.extend_from_slice(&2u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&2048u32.to_le_bytes()); + wav.extend_from_slice(&vec![0u8; 2048]); + + entries[0].data = bank::to_wem(&wav, None).unwrap(); + let saved = bank::save_with_entries(&original, &entries).unwrap(); + + let stored = Bank::parse(&saved).unwrap().entry(target).unwrap().to_vec(); + let wem = Wem::new(&stored).expect("a wav must be encoded into a real wem"); + assert_eq!(wem.format().sample_rate, 44100); + assert_eq!( + wem.format().codec, + Wem::new(&original_payload).unwrap().format().codec, + "a replacement must use the same codec as the sound it replaces" + ); + bank::decode_wem(&stored).expect("and it must decode back"); + assert!( + stored.len() < wav.len(), + "encoded {} bytes from a {} byte wav — that is PCM, not Vorbis", + stored.len(), + wav.len() + ); +} diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 020a550f..371e11f0 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,83 +1,44 @@ /* Sound Banks (BnkExtract) backend. -Pure-Rust BNK/WPK parsing, HIRC event mapping and WEM->OGG/WAV decoding live in -quartz-lib::audio. WEM encoding and MP3 decoding need external tools -(WwiseConsole.exe + vgmstream-cli.exe) fetched from the tarngaina/LtMAO repo into -%APPDATA%/RitoShark/AudioTools, mirroring the original Electron handler. */ +Everything here is in-process. BNK/WPK containers, WEM decoding and Wwise Vorbis encoding come +from ritoshark::audio; user-supplied mp3/flac/ogg/m4a is decoded by quartz_lib::audio::decode. + +This used to download WwiseConsole.exe and vgmstream-cli.exe from a third-party repo into +%APPDATA%/RitoShark/AudioTools and shell out to them. The Rust encoder replaced the only thing +that genuinely needed an external toolchain, so the download is gone and any copy a previous +version left behind is removed on startup. */ use base64::Engine; +use quartz_lib::audio::bank; +use quartz_lib::audio::decode; use quartz_lib::audio::tree::{self, LoadBanksResult}; -use quartz_lib::audio::wem; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Emitter}; // --------------------------------------------------------------------------- -// Tool paths (shared RitoShark appdata dir) +// Legacy toolchain cleanup // --------------------------------------------------------------------------- -fn audio_tools_root() -> Result { - let appdata = std::env::var("APPDATA").map_err(|_| "APPDATA not set".to_string())?; - Ok(PathBuf::from(appdata).join("RitoShark").join("AudioTools")) -} - -fn wwise_console_exe() -> Result { - Ok(audio_tools_root()? - .join("Wwise") - .join("WwiseApp") - .join("Authoring") - .join("x64") - .join("Release") - .join("bin") - .join("WwiseConsole.exe")) -} - -fn wwise_wproj() -> Result { - Ok(audio_tools_root()? - .join("Wwise") - .join("WwiseLeagueProjects") - .join("WWiseLeagueProjects.wproj")) -} - -fn vgmstream_exe() -> Result { - Ok(audio_tools_root()? - .join("Decoders") - .join("vgmstream-cli.exe")) +fn audio_tools_root() -> Option { + std::env::var("APPDATA") + .ok() + .map(|appdata| PathBuf::from(appdata).join("RitoShark").join("AudioTools")) } -fn wwise_temp_dir() -> Result { - Ok(audio_tools_root()?.join("Temp")) -} +/** Deletes the Wwise/vgmstream toolchain older versions downloaded. -/// Spawn a child process hidden (no console window on Windows) and wait for it. -fn run_hidden(exe: &Path, args: &[&str], cwd: Option<&Path>) -> Result<(), String> { - use std::process::Command; - let mut cmd = Command::new(exe); - cmd.args(args); - if let Some(dir) = cwd { - cmd.current_dir(dir); +Several hundred megabytes of third-party binaries that nothing reads any more. Called once at +startup; a missing directory is the normal case and not an error. */ +pub fn remove_legacy_audio_tools() { + let Some(root) = audio_tools_root() else { + return; + }; + if !root.exists() { + return; } - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); - } - let output = cmd - .output() - .map_err(|e| format!("Failed to launch {}: {e}", exe.display()))?; - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "{} exited with {}: {}", - exe.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("process"), - output.status, - stderr.trim() - )) + match std::fs::remove_dir_all(&root) { + Ok(()) => tracing::info!("removed the legacy audio toolchain at {}", root.display()), + Err(e) => tracing::warn!("could not remove {}: {e}", root.display()), } } @@ -100,56 +61,34 @@ pub async fn bnk_load_banks(args: LoadBanksArgs) -> Result, want_wav: bool) -> Result, String> { - let decoded = wem::decode_wem(&data)?; - if want_wav && decoded.format != "wav" { - // The decoder returns OGG for Vorbis WEMs; the UI only needs a playable - // container, and an OGG is acceptable where WAV was requested for non-PCM - // sources. Hand back whatever the decoder produced. - return Ok(decoded.data); - } - Ok(decoded.data) -} - -/// Decode raw WEM bytes to a playable container (OGG or WAV). +/// Decode raw WEM bytes to a playable container (OGG for Vorbis, WAV for PCM). #[tauri::command] pub async fn bnk_wem_to_ogg(data: Vec) -> Result, String> { - tokio::task::spawn_blocking(move || decode_to(data, false)) + tokio::task::spawn_blocking(move || bank::decode_wem(&data).map(|d| d.data)) .await .map_err(|e| format!("wem decode task failed: {e}"))? } -/// Decode raw WEM bytes to WAV/OGG PCM for extraction. +/// Decode raw WEM bytes all the way to a WAV, whatever the source codec was. #[tauri::command] pub async fn bnk_wem_to_wav(data: Vec) -> Result, String> { - tokio::task::spawn_blocking(move || decode_to(data, true)) + tokio::task::spawn_blocking(move || bank::wem_to_pcm(&data).map(|pcm| bank::pcm_to_wav(&pcm))) .await .map_err(|e| format!("wem decode task failed: {e}"))? } -/// Decode a WEM, then transcode to MP3 via vgmstream + the system tools. When the -/// tools are missing we fall back to the decoded OGG/WAV bytes so extraction still -/// produces a file. +/// There is no MP3 encoder here, so this yields the decoded container instead. #[tauri::command] pub async fn bnk_wem_to_mp3(data: Vec, bitrate: u32) -> Result, String> { let _ = bitrate; - tokio::task::spawn_blocking(move || decode_to(data, false)) - .await - .map_err(|e| format!("wem decode task failed: {e}"))? -} - -/// The packed codebook bundled with the decoder. Returned so the frontend can keep -/// its loadCodebook() contract, though the Rust decoder embeds its own copy. -#[tauri::command] -pub async fn bnk_load_codebook() -> Result, String> { - Ok(wem::codebook_bytes().to_vec()) + bnk_wem_to_ogg(data).await } // --------------------------------------------------------------------------- @@ -164,6 +103,9 @@ pub struct ExtractNode { pub audio_data: Option, #[serde(default)] pub children: Option>, + /// Set on a root node — the container the tree was loaded from. + #[serde(default)] + pub original_path: Option, } #[derive(Debug, Deserialize)] @@ -178,6 +120,8 @@ pub struct ExtractAudio { pub struct ExtractArgs { pub nodes: Vec, pub formats: Vec, + /// Part of the frontend's payload; unread because there is no MP3 encoder. + #[allow(dead_code)] pub mp3_bitrate: u32, pub out_dir: String, } @@ -205,32 +149,20 @@ fn write_node_formats( .map_err(|e| format!("write wem failed: {e}"))?; *count += 1; } - if formats.iter().any(|f| f == "ogg") { - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let ext = if decoded.format == "wav" { - "wav" - } else { - "ogg" - }; + // ogg and mp3 both land as the decoded container; only wav is converted. + if formats.iter().any(|f| f == "ogg" || f == "mp3") { + if let Ok(decoded) = bank::decode_wem(&audio.data) { + let ext = if decoded.format == "wav" { "wav" } else { "ogg" }; let _ = std::fs::write(cur_dir.join(format!("{base}.{ext}")), &decoded.data); *count += 1; } } if formats.iter().any(|f| f == "wav") { - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let _ = std::fs::write(cur_dir.join(format!("{base}.wav")), &decoded.data); - *count += 1; - } - } - if formats.iter().any(|f| f == "mp3") { - // No native MP3 encoder; emit the decoded container so a file still lands. - if let Ok(decoded) = wem::decode_wem(&audio.data) { - let ext = if decoded.format == "wav" { - "wav" - } else { - "ogg" - }; - let _ = std::fs::write(cur_dir.join(format!("{base}.{ext}")), &decoded.data); + if let Ok(pcm) = bank::wem_to_pcm(&audio.data) { + let _ = std::fs::write( + cur_dir.join(format!("{base}.wav")), + bank::pcm_to_wav(&pcm), + ); *count += 1; } } @@ -278,10 +210,13 @@ pub struct SaveBankArgs { pub out_path: String, } -/// Collect every audio leaf under a node into (id, data) pairs. -fn collect_audio(node: &ExtractNode, out: &mut Vec<(u32, Vec)>) { +/// Collect every audio leaf under a node into entries. +fn collect_audio(node: &ExtractNode, out: &mut Vec) { if let Some(audio) = &node.audio_data { - out.push((audio.id, audio.data.clone())); + out.push(bank::AudioEntry { + id: audio.id, + data: audio.data.clone(), + }); } if let Some(children) = &node.children { for child in children { @@ -290,29 +225,29 @@ fn collect_audio(node: &ExtractNode, out: &mut Vec<(u32, Vec)>) { } } -/// Serialize a root node's audio back into a .bnk or .wpk container. +/** Write a root node's audio back into its container. + +The edit is applied to the bank the tree was loaded from rather than to a fresh one, so the header +revision, the bank id and the object hierarchy survive. Rebuilding from scratch — which is what +this did before — produced a bank the engine could not load however good the audio inside it was, +so a missing source is an error rather than a silent fallback. */ #[tauri::command] pub async fn bnk_save_bank(args: SaveBankArgs) -> Result<(), String> { - use quartz_lib::audio::bnk::{self, AudioEntry}; - use quartz_lib::audio::wpk; - tokio::task::spawn_blocking(move || { - let mut pairs: Vec<(u32, Vec)> = Vec::new(); - collect_audio(&args.root, &mut pairs); - pairs.sort_by_key(|p| p.0); - pairs.dedup_by_key(|p| p.0); - - let entries: Vec = pairs - .into_iter() - .map(|(id, data)| AudioEntry { id, data }) - .collect(); - - let lower = args.out_path.to_lowercase(); - let bytes = if lower.ends_with(".wpk") { - wpk::write_wpk(&entries) - } else { - bnk::write_bnk(&entries) - }; + let source = args + .root + .original_path + .as_deref() + .ok_or("This bank has no source file recorded, so it cannot be saved safely")?; + let original = std::fs::read(source) + .map_err(|e| format!("could not read the source bank '{source}': {e}"))?; + + let mut entries: Vec = Vec::new(); + collect_audio(&args.root, &mut entries); + entries.sort_by_key(|e| e.id); + entries.dedup_by_key(|e| e.id); + + let bytes = bank::save_with_entries(&original, &entries)?; std::fs::write(&args.out_path, bytes).map_err(|e| format!("write bank failed: {e}")) }) .await @@ -320,152 +255,19 @@ pub async fn bnk_save_bank(args: SaveBankArgs) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Wwise / vgmstream tooling +// Conversion // --------------------------------------------------------------------------- -#[tauri::command] -pub async fn wwise_check() -> Result { - Ok(wwise_console_exe()?.exists()) -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallResult { - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Deserialize)] -struct GitTreeItem { - path: String, - #[serde(rename = "type")] - kind: String, -} - -#[derive(Debug, Deserialize)] -struct GitTree { - tree: Vec, -} - -/// Download the wiwawe (Wwise) + vgmstream tool files from tarngaina/LtMAO, -/// emitting `wwise:install-progress` events as it goes. -#[tauri::command] -pub async fn wwise_install(app: AppHandle) -> Result { - const REPO: &str = "tarngaina/LtMAO"; - const BRANCH: &str = "hai"; - let tree_api = format!("https://api.github.com/repos/{REPO}/git/trees/{BRANCH}?recursive=1"); - let raw_base = format!("https://raw.githubusercontent.com/{REPO}/{BRANCH}/"); - - let wanted_prefixes = ["res/wiwawe/", "res/tools/vgmstream/"]; - let dest_map: [(&str, PathBuf); 2] = [ - ("res/wiwawe/", audio_tools_root()?.join("Wwise")), - ("res/tools/vgmstream/", audio_tools_root()?.join("Decoders")), - ]; - - let progress = |msg: &str| { - let _ = app.emit("wwise:install-progress", msg.to_string()); - }; - - let result: Result<(), String> = async { - std::fs::create_dir_all(audio_tools_root()?).map_err(|e| e.to_string())?; - std::fs::create_dir_all(wwise_temp_dir()?).map_err(|e| e.to_string())?; - - let client = reqwest::Client::builder() - .user_agent("Quartz-App") - .build() - .map_err(|e| e.to_string())?; - - progress("Fetching file list from GitHub..."); - let tree: GitTree = client - .get(&tree_api) - .send() - .await - .map_err(|e| format!("GitHub tree request failed: {e}"))? - .json() - .await - .map_err(|e| format!("GitHub tree parse failed: {e}"))?; - - let files: Vec<&GitTreeItem> = tree - .tree - .iter() - .filter(|item| { - item.kind == "blob" && wanted_prefixes.iter().any(|p| item.path.starts_with(p)) - }) - .collect(); - - if files.is_empty() { - return Err("No files found — repo structure may have changed".into()); - } - - let total = files.len(); - progress(&format!("Installing audio tools (0 / {total} files)...")); - - let mut done = 0usize; - for item in files { - if item.path.contains("..") { - continue; - } - let mapping = dest_map.iter().find(|(p, _)| item.path.starts_with(p)); - let (prefix, dest) = match mapping { - Some(m) => m, - None => continue, - }; - let rel = &item.path[prefix.len()..]; - let dest_path = rel.split('/').fold(dest.clone(), |acc, seg| acc.join(seg)); - - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - - let bytes = client - .get(format!("{raw_base}{}", item.path)) - .send() - .await - .map_err(|e| format!("download failed for {}: {e}", item.path))? - .bytes() - .await - .map_err(|e| format!("download read failed for {}: {e}", item.path))?; - std::fs::write(&dest_path, &bytes) - .map_err(|e| format!("write failed for {}: {e}", dest_path.display()))?; - - done += 1; - progress(&format!( - "Installing audio tools ({done} / {total} files)..." - )); - } - - if !wwise_console_exe()?.exists() { - return Err( - "WwiseConsole.exe not found after install — repo structure may have changed." - .into(), - ); - } - - progress("Done!"); - Ok(()) - } - .await; - - match result { - Ok(()) => Ok(InstallResult { - success: true, - error: None, - }), - Err(e) => Ok(InstallResult { - success: false, - error: Some(e), - }), - } -} - -/// Convert a user wav/mp3/ogg file to .wem via vgmstream + WwiseConsole. Returns -/// the encoded WEM bytes. +/// Convert a user audio file on disk to a Wwise Vorbis WEM. #[tauri::command] pub async fn audio_convert_to_wem(input_path: String) -> Result, String> { - tokio::task::spawn_blocking(move || convert_to_wem_blocking(&input_path)) - .await - .map_err(|e| format!("convert task failed: {e}"))? + tokio::task::spawn_blocking(move || { + let data = std::fs::read(&input_path) + .map_err(|e| format!("could not read '{input_path}': {e}"))?; + bank::to_wem(&data, None) + }) + .await + .map_err(|e| format!("convert task failed: {e}"))? } #[derive(Debug, Deserialize)] @@ -483,297 +285,44 @@ pub struct BatchWemOutput { pub error: Option, } -/// Convert splitter WAV segments in one WwiseConsole invocation, matching the -/// Electron workflow without exposing temporary paths to the webview. +/// Convert splitter segments to WEM. One failure does not sink the batch. #[tauri::command] pub async fn audio_convert_wavs_to_wem( inputs: Vec, ) -> Result, String> { - tokio::task::spawn_blocking(move || convert_wavs_to_wem_blocking(inputs)) - .await - .map_err(|e| format!("batch convert task failed: {e}"))? -} - -fn xml_attribute(value: &str) -> String { - value - .replace('&', "&") - .replace('"', """) - .replace('<', "<") - .replace('>', ">") -} - -fn safe_audio_stem(name: &str) -> String { - let raw = Path::new(name) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("segment"); - let safe: String = raw - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || character == '_' || character == '-' { - character - } else { - '_' - } - }) - .collect(); - if safe.is_empty() { - "segment".to_string() - } else { - safe - } -} - -fn convert_wavs_to_wem_blocking(inputs: Vec) -> Result, String> { - if inputs.is_empty() { - return Ok(Vec::new()); - } - - let console = wwise_console_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|error| error.to_string())?; - let uid = unique_id(); - let wsources = temp.join(format!("split_batch_{uid}.wsources")); - - let mut jobs: Vec<(String, String, PathBuf)> = Vec::with_capacity(inputs.len()); - for (index, input) in inputs.into_iter().enumerate() { - let destination = format!("split_{uid}_{index}_{}", safe_audio_stem(&input.name)); - let wav_path = temp.join(format!("{destination}.wav")); - if let Err(error) = std::fs::write(&wav_path, input.data) { - for (_, _, path) in &jobs { - let _ = std::fs::remove_file(path); - } - return Err(format!("write splitter wav failed: {error}")); - } - jobs.push((input.name, destination, wav_path)); - } - - let mut xml = format!( - "\n\n", - xml_attribute(&temp.to_string_lossy()) - ); - for (_, destination, wav_path) in &jobs { - xml.push_str(&format!( - " \n", - xml_attribute(&wav_path.to_string_lossy()), - xml_attribute(destination), - )); - } - xml.push_str(""); - std::fs::write(&wsources, xml).map_err(|error| error.to_string())?; - - let wproj = wwise_wproj()?; - let conversion = run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - ); - - let _ = std::fs::remove_file(&wsources); - for (_, _, wav_path) in &jobs { - let _ = std::fs::remove_file(wav_path); - } - conversion?; - - let outputs = jobs - .into_iter() - .map(|(name, destination, _)| { - let candidates = [ - temp.join("Windows").join(format!("{destination}.wem")), - temp.join(format!("{destination}.wem")), - ]; - let wem_path = candidates.iter().find(|path| path.exists()); - let result = match wem_path { - Some(path) => std::fs::read(path) - .map(|data| base64::engine::general_purpose::STANDARD.encode(data)) - .map_err(|error| format!("read converted WEM failed: {error}")), - None => Err("Wwise did not produce a WEM file".to_string()), - }; - if let Some(path) = wem_path { - let _ = std::fs::remove_file(path); - } - match result { - Ok(data_base64) => BatchWemOutput { - name, - data_base64: Some(data_base64), + tokio::task::spawn_blocking(move || { + inputs + .into_iter() + .map(|input| match bank::to_wem(&input.data, None) { + Ok(wem) => BatchWemOutput { + name: input.name, + data_base64: Some(base64::engine::general_purpose::STANDARD.encode(wem)), error: None, }, Err(error) => BatchWemOutput { - name, + name: input.name, data_base64: None, error: Some(error), }, - } - }) - .collect(); - - Ok(outputs) -} - -fn unique_id() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{nanos}") -} - -fn convert_to_wem_blocking(input_path: &str) -> Result, String> { - let console = wwise_console_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - - let input = Path::new(input_path); - let ext = input - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - let base = input - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("audio") - .to_string(); - let uid = unique_id(); - let dest = format!("{base}_{uid}"); - - let mut wav_path = input.to_path_buf(); - let mut temp_files: Vec = Vec::new(); - - // MP3/OGG -> WAV via vgmstream. - if ext == "mp3" || ext == "ogg" { - let vgm = vgmstream_exe()?; - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let out_wav = temp.join(format!("{dest}.wav")); - run_hidden( - &vgm, - &["-o", &out_wav.to_string_lossy(), input_path], - vgm.parent(), - )?; - temp_files.push(out_wav.clone()); - wav_path = out_wav; - } - - // Normalize to signed 16-bit PCM so WwiseConsole always reads it. - let raw = std::fs::read(&wav_path).map_err(|e| format!("read wav failed: {e}"))?; - if let Some(norm) = normalize_wav_to_s16(&raw) { - let norm_path = temp.join(format!("{dest}_norm.wav")); - std::fs::write(&norm_path, &norm).map_err(|e| e.to_string())?; - temp_files.push(norm_path.clone()); - wav_path = norm_path; - } - - // Build .wsources and run WwiseConsole. - let wsources = temp.join(format!("{dest}.wsources")); - let xml = format!( - "\n\n \n", - root = temp.to_string_lossy(), - src = wav_path.to_string_lossy(), - dest = dest, - ); - std::fs::write(&wsources, xml).map_err(|e| e.to_string())?; - - let wproj = wwise_wproj()?; - run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - )?; - - let candidates = [ - temp.join("Windows").join(format!("{dest}.wem")), - temp.join(format!("{dest}.wem")), - ]; - let wem_path = candidates.iter().find(|p| p.exists()); - - let result = match wem_path { - Some(p) => std::fs::read(p).map_err(|e| format!("read wem failed: {e}")), - None => Err("Conversion succeeded but .wem output not found".into()), - }; - - let _ = std::fs::remove_file(&wsources); - for f in &temp_files { - let _ = std::fs::remove_file(f); - } - if let Some(p) = wem_path { - let _ = std::fs::remove_file(p); - } - - result + }) + .collect() + }) + .await + .map_err(|e| format!("batch convert task failed: {e}")) } -/// Decode a WEM/MP3/OGG to WAV bytes via the native decoder or vgmstream. -/// The base64 result avoids leaving splitter temp files behind and is much -/// smaller on the IPC boundary than a JSON array containing every byte. +/// Decode a WEM, MP3, OGG, FLAC or WAV to WAV bytes, base64 for the IPC boundary. #[tauri::command] pub async fn audio_decode_to_wav(data: Vec) -> Result { tokio::task::spawn_blocking(move || { - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - let uid = unique_id(); - - // Try the native decoder first (handles WEM directly). - if let Ok(decoded) = wem::decode_wem(&data) { - if decoded.format == "wav" { - return Ok(base64::engine::general_purpose::STANDARD.encode(decoded.data)); - } - } - - // Fall back to vgmstream on a temp input file. - let vgm = vgmstream_exe()?; - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let in_path = temp.join(format!("split_in_{uid}.bin")); - std::fs::write(&in_path, &data).map_err(|e| e.to_string())?; - let out = temp.join(format!("split_{uid}.wav")); - let res = run_hidden( - &vgm, - &["-o", &out.to_string_lossy(), &in_path.to_string_lossy()], - vgm.parent(), - ); - let _ = std::fs::remove_file(&in_path); - if let Err(error) = res { - let _ = std::fs::remove_file(&out); - return Err(error); - } - let wav = std::fs::read(&out).map_err(|e| format!("read decoded wav failed: {e}")); - let _ = std::fs::remove_file(&out); - Ok(base64::engine::general_purpose::STANDARD.encode(wav?)) + let pcm = bank::wem_to_pcm(&data).or_else(|_| decode::decode_any(&data))?; + Ok(base64::engine::general_purpose::STANDARD.encode(bank::pcm_to_wav(&pcm))) }) .await .map_err(|e| format!("decode task failed: {e}"))? } -/// Write raw bytes to a path, creating parent directories. Used by the audio -/// splitter to save sliced WAV segments the frontend encodes in JS. +/// Write raw bytes to a path, creating parent directories. #[tauri::command] pub async fn audio_write_file(path: String, data: Vec) -> Result<(), String> { tokio::task::spawn_blocking(move || { @@ -786,85 +335,15 @@ pub async fn audio_write_file(path: String, data: Vec) -> Result<(), String> .map_err(|e| format!("write task failed: {e}"))? } -/// Amplify a WEM by gain_db: decode -> scale PCM -> re-encode through Wwise. +/// Amplify a WEM by gain_db, re-encoding into the codec it already used. #[tauri::command] pub async fn audio_amplify_wem(data: Vec, gain_db: f32) -> Result, String> { - tokio::task::spawn_blocking(move || { - let console = wwise_console_exe()?; - let vgm = vgmstream_exe()?; - if !console.exists() { - return Err("Wwise tools not installed".into()); - } - if !vgm.exists() { - return Err("vgmstream decoder not installed".into()); - } - let temp = wwise_temp_dir()?; - std::fs::create_dir_all(&temp).map_err(|e| e.to_string())?; - let uid = unique_id(); - let base = format!("gain_{uid}"); - - // WEM -> WAV via vgmstream. - let in_wem = temp.join(format!("{base}.wem")); - std::fs::write(&in_wem, &data).map_err(|e| e.to_string())?; - let wav_path = temp.join(format!("{base}.wav")); - let dec = run_hidden( - &vgm, - &["-o", &wav_path.to_string_lossy(), &in_wem.to_string_lossy()], - vgm.parent(), - ); - let _ = std::fs::remove_file(&in_wem); - dec?; - - // Amplify PCM in place. - let raw = std::fs::read(&wav_path).map_err(|e| e.to_string())?; - let amplified = amplify_wav(&raw, gain_db); - std::fs::write(&wav_path, &lified).map_err(|e| e.to_string())?; - - // WAV -> WEM via WwiseConsole. - let wsources = temp.join(format!("{base}.wsources")); - let xml = format!( - "\n\n \n", - root = temp.to_string_lossy(), - src = wav_path.to_string_lossy(), - dest = base, - ); - std::fs::write(&wsources, xml).map_err(|e| e.to_string())?; - let wproj = wwise_wproj()?; - run_hidden( - &console, - &[ - "convert-external-source", - &wproj.to_string_lossy(), - "--source-file", - &wsources.to_string_lossy(), - "--output", - &temp.to_string_lossy(), - "--platform", - "Windows", - ], - console.parent(), - )?; - - let candidates = [ - temp.join("Windows").join(format!("{base}.wem")), - temp.join(format!("{base}.wem")), - ]; - let wem_path = candidates.iter().find(|p| p.exists()); - let result = match wem_path { - Some(p) => std::fs::read(p).map_err(|e| e.to_string()), - None => Err("Output WEM not found after conversion".into()), - }; - let _ = std::fs::remove_file(&wav_path); - let _ = std::fs::remove_file(&wsources); - if let Some(p) = wem_path { - let _ = std::fs::remove_file(p); - } - result - }) - .await - .map_err(|e| format!("amplify task failed: {e}"))? + tokio::task::spawn_blocking(move || bank::amplify_wem(&data, gain_db)) + .await + .map_err(|e| format!("amplify task failed: {e}"))? } + // --------------------------------------------------------------------------- // Mod folder scan + game extraction // --------------------------------------------------------------------------- @@ -904,14 +383,29 @@ pub async fn bnk_scan_mod_folder( skin_id: Option, ) -> Result, String> { tokio::task::spawn_blocking(move || { - let root = PathBuf::from(&folder_path); + Ok(scan_folder_sets( + &PathBuf::from(&folder_path), + skin_id.as_deref(), + )) + }) + .await + .map_err(|e| format!("scan task failed: {e}"))? +} + +/** Every audio/events/bin triple under `root`, best BIN first. + +Shared by the folder scan and by the single-BIN lookup so both agree on which bank pairs with +which — the matching rules here are the ones ported from the original Quartz mod processor and +are not worth having two copies of. */ +fn scan_folder_sets(root: &Path, skin_id: Option<&str>) -> Vec { + { if !root.exists() { - return Ok(Vec::new()); + return Vec::new(); } let mut all: Vec = Vec::new(); - walk_files(&root, &mut all); + walk_files(root, &mut all); - let skin = skin_id.as_deref().filter(|s| !s.is_empty()); + let skin = skin_id.filter(|s| !s.is_empty()); let skin_matches = |p: &Path| -> bool { match skin { None => true, @@ -964,7 +458,7 @@ pub async fn bnk_scan_mod_folder( score }; // Highest score first (stable so ties keep discovery order). - bins.sort_by(|a, b| bin_score(b).cmp(&bin_score(a))); + bins.sort_by_key(|b| std::cmp::Reverse(bin_score(b))); let selected_bin = bins .iter() .find(|p| skin_matches(p)) @@ -1093,10 +587,84 @@ pub async fn bnk_scan_mod_folder( }) .collect(); - Ok(sets) + sets + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocatedBanks { + pub audio: String, + pub events: String, +} + +/// The `skinNN` number in a path, which is what pairs a BIN with its banks. +fn skin_id_from_path(path: &Path) -> Option { + let lowered = path.to_string_lossy().to_lowercase(); + let at = lowered.rfind("skin")?; + let digits: String = lowered[at + 4..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + (!digits.is_empty()).then(|| digits.trim_start_matches('0').to_string()) + .map(|trimmed| if trimmed.is_empty() { "0".into() } else { trimmed }) +} + +/** The directory to search for banks given a BIN somewhere inside a mod. + +A skin BIN sits under `.../data/characters//skins/` while its audio sits under +`.../assets/sounds/...`, so the search has to start from a shared ancestor. Walking up to the +directory holding `data` or `assets` finds the WAD root; failing that, a few levels up is still +better than the BIN's own folder, which never contains banks. */ +fn bank_search_root(bin: &Path) -> PathBuf { + let mut ancestors: Vec<&Path> = bin.ancestors().skip(1).collect(); + for dir in &ancestors { + let holds_root_marker = std::fs::read_dir(dir).is_ok_and(|entries| { + entries.flatten().any(|e| { + let name = e.file_name().to_string_lossy().to_lowercase(); + e.path().is_dir() && (name == "assets" || name == "data" || name == "sounds") + }) + }); + if holds_root_marker { + return dir.to_path_buf(); + } + } + ancestors.truncate(4); + ancestors + .last() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| bin.to_path_buf()) +} + +/** Find the audio and events banks that belong to a BIN the user just picked. + +Reuses the mod-folder matching, so the pairing is the same one the folder drop already produces. +Returns `None` rather than a guess when nothing convincing is nearby. */ +#[tauri::command] +pub async fn bnk_locate_banks_for_bin(bin_path: String) -> Result, String> { + tokio::task::spawn_blocking(move || { + let bin = PathBuf::from(&bin_path); + if !bin.is_file() { + return None; + } + let skin = skin_id_from_path(&bin); + let root = bank_search_root(&bin); + + let sets = scan_folder_sets(&root, skin.as_deref()); + // Prefer a set that resolved an events bank too — that is the pairing the + // tree actually needs to name anything. + let best = sets + .iter() + .find(|s| !s.events.is_empty()) + .or_else(|| sets.first())?; + + Some(LocatedBanks { + audio: best.audio.clone(), + events: best.events.clone(), + }) }) .await - .map_err(|e| format!("scan task failed: {e}"))? + .map_err(|e| format!("locate task failed: {e}")) } #[derive(Debug, Serialize)] @@ -1548,164 +1116,48 @@ fn extract_banks_from_game_blocking(args: GameBanksArgs) -> Result u32 { - u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) -} +#[cfg(test)] +mod tests { + use super::*; -fn read_u16_le(b: &[u8], o: usize) -> u16 { - u16::from_le_bytes([b[o], b[o + 1]]) -} - -/// Find the fmt + data chunks. Returns (audio_format, bits, channels, sample_rate, -/// data_start, data_size). -fn parse_wav(buf: &[u8]) -> Option<(u16, u16, u16, u32, usize, usize)> { - if buf.len() < 12 { - return None; - } - let mut pos = 12usize; - let mut audio_format = 1u16; - let mut channels = 1u16; - let mut sample_rate = 44100u32; - let mut bits = 16u16; - while pos + 8 <= buf.len() { - let id = &buf[pos..pos + 4]; - let size = read_u32_le(buf, pos + 4) as usize; - if id == b"fmt " && pos + 24 <= buf.len() { - audio_format = read_u16_le(buf, pos + 8); - channels = read_u16_le(buf, pos + 10); - sample_rate = read_u32_le(buf, pos + 12); - bits = read_u16_le(buf, pos + 22); - } else if id == b"data" { - return Some((audio_format, bits, channels, sample_rate, pos + 8, size)); + #[test] + fn skin_number_comes_from_the_bin_path() { + let cases = [ + ("mod/data/characters/aatrox/skins/skin0.bin", Some("0")), + ("mod/DATA/Characters/Ahri/Skins/Skin12.bin", Some("12")), + // Zero padding is how Riot writes it; the banks are not padded. + ("mod/data/characters/yone/skins/skin07.bin", Some("7")), + ("mod/data/characters/yone/yone.bin", None), + ]; + for (path, expected) in cases { + assert_eq!( + skin_id_from_path(Path::new(path)).as_deref(), + expected, + "{path}" + ); } - pos += 8 + if size % 2 != 0 { size + 1 } else { size }; } - None -} -/// Amplify a PCM/float WAV buffer by gain_db decibels in place. -fn amplify_wav(buf: &[u8], gain_db: f32) -> Vec { - let gain = 10f32.powf(gain_db / 20.0); - let mut out = buf.to_vec(); - let (audio_format, bits, _ch, _sr, data_start, data_size) = match parse_wav(buf) { - Some(v) => v, - None => return out, - }; - let end = (data_start + data_size).min(out.len()); - - if audio_format == 1 && bits == 16 { - let mut i = data_start; - while i + 1 < end { - let s = i16::from_le_bytes([out[i], out[i + 1]]) as f32 * gain; - let clamped = s.round().clamp(-32768.0, 32767.0) as i16; - out[i..i + 2].copy_from_slice(&clamped.to_le_bytes()); - i += 2; - } - } else if audio_format == 1 && bits == 24 { - let mut i = data_start; - while i + 2 < end { - let mut s = (out[i] as i32) | ((out[i + 1] as i32) << 8) | ((out[i + 2] as i32) << 16); - if s & 0x80_0000 != 0 { - s |= !0xFF_FFFF; - } - let v = ((s as f32 * gain).round()).clamp(-8_388_608.0, 8_388_607.0) as i32; - out[i] = (v & 0xFF) as u8; - out[i + 1] = ((v >> 8) & 0xFF) as u8; - out[i + 2] = ((v >> 16) & 0xFF) as u8; - i += 3; - } - } else if audio_format == 1 && bits == 32 { - let mut i = data_start; - while i + 3 < end { - let s = i32::from_le_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as f32 * gain; - let v = s.round().clamp(i32::MIN as f32, i32::MAX as f32) as i32; - out[i..i + 4].copy_from_slice(&v.to_le_bytes()); - i += 4; - } - } else if audio_format == 3 && bits == 32 { - let mut i = data_start; - while i + 3 < end { - let s = f32::from_le_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) * gain; - out[i..i + 4].copy_from_slice(&s.clamp(-1.0, 1.0).to_le_bytes()); - i += 4; - } + #[test] + fn the_last_skin_token_wins_over_an_earlier_one() { + // A mod folder can itself be named "Skin3 Something"; the file is what counts. + let path = Path::new("mods/Skin3 recolor/data/characters/ahri/skins/skin5.bin"); + assert_eq!(skin_id_from_path(path).as_deref(), Some("5")); } - out -} -/// Convert any WAV to signed-16-bit PCM. Returns None if already S16 (no change -/// needed) so callers can skip the rewrite. -fn normalize_wav_to_s16(buf: &[u8]) -> Option> { - let (audio_format, bits, channels, sample_rate, data_start, data_size) = parse_wav(buf)?; - if audio_format == 1 && bits == 16 { - return None; - } - let data_end = (data_start + data_size).min(buf.len()); - let data = &buf[data_start..data_end]; + #[test] + fn the_search_root_climbs_past_the_bin_folder() { + let dir = std::env::temp_dir().join("quartz_bank_root_test"); + let bin_dir = dir.join("WAD/data/characters/ahri/skins"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::create_dir_all(dir.join("WAD/assets/sounds")).unwrap(); + let bin = bin_dir.join("skin0.bin"); + std::fs::write(&bin, b"PROP").unwrap(); - let mut samples: Vec = Vec::new(); - if audio_format == 1 && bits == 8 { - for &b in data { - samples.push(((b as i16) - 128) << 8); - } - } else if audio_format == 1 && bits == 24 { - let n = data.len() / 3; - for i in 0..n { - let mut s = (data[i * 3] as i32) - | ((data[i * 3 + 1] as i32) << 8) - | ((data[i * 3 + 2] as i32) << 16); - if s & 0x80_0000 != 0 { - s |= !0xFF_FFFF; - } - samples.push((s >> 8) as i16); - } - } else if audio_format == 1 && bits == 32 { - let n = data.len() / 4; - for i in 0..n { - let s = i32::from_le_bytes([ - data[i * 4], - data[i * 4 + 1], - data[i * 4 + 2], - data[i * 4 + 3], - ]); - samples.push((s >> 16) as i16); - } - } else if audio_format == 3 && bits == 32 { - let n = data.len() / 4; - for i in 0..n { - let f = f32::from_le_bytes([ - data[i * 4], - data[i * 4 + 1], - data[i * 4 + 2], - data[i * 4 + 3], - ]); - samples.push((f.clamp(-1.0, 1.0) * 32767.0).round() as i16); - } - } else { - return None; - } + // data/ and assets/ are siblings under WAD, which is where banks live. + assert_eq!(bank_search_root(&bin), dir.join("WAD")); - let new_data_size = samples.len() * 2; - let mut out = Vec::with_capacity(44 + new_data_size); - out.extend_from_slice(b"RIFF"); - out.extend_from_slice(&((36 + new_data_size) as u32).to_le_bytes()); - out.extend_from_slice(b"WAVE"); - out.extend_from_slice(b"fmt "); - out.extend_from_slice(&16u32.to_le_bytes()); - out.extend_from_slice(&1u16.to_le_bytes()); - out.extend_from_slice(&channels.to_le_bytes()); - out.extend_from_slice(&sample_rate.to_le_bytes()); - out.extend_from_slice(&(sample_rate * channels as u32 * 2).to_le_bytes()); - out.extend_from_slice(&(channels * 2).to_le_bytes()); - out.extend_from_slice(&16u16.to_le_bytes()); - out.extend_from_slice(b"data"); - out.extend_from_slice(&(new_data_size as u32).to_le_bytes()); - for s in samples { - out.extend_from_slice(&s.to_le_bytes()); + std::fs::remove_dir_all(&dir).ok(); } - Some(out) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e157e58d..36d08d66 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -78,6 +78,9 @@ fn main() { if let Err(e) = commands::context_menu::context_menu_refresh_if_enabled() { tracing::warn!("Failed to refresh Explorer context menu: {}", e); } + // Audio encoding is in-process now; drop the toolchain older + // versions downloaded into %APPDATA% and never cleaned up. + std::thread::spawn(commands::audio::remove_legacy_audio_tools); // Seed bundled wallpapers/cursors so themed presets have their images. if let Ok(resource_dir) = app.path().resource_dir() { commands::assets::seed_bundled_assets(&resource_dir); @@ -256,16 +259,14 @@ fn main() { commands::audio::bnk_wem_to_ogg, commands::audio::bnk_wem_to_wav, commands::audio::bnk_wem_to_mp3, - commands::audio::bnk_load_codebook, commands::audio::bnk_extract_nodes, commands::audio::bnk_save_bank, - commands::audio::wwise_check, - commands::audio::wwise_install, commands::audio::audio_convert_to_wem, commands::audio::audio_convert_wavs_to_wem, commands::audio::audio_decode_to_wav, commands::audio::audio_amplify_wem, commands::audio::bnk_scan_mod_folder, + commands::audio::bnk_locate_banks_for_bin, commands::audio::bnk_extract_banks_from_game, commands::audio::audio_write_file, commands::bnk_session::bnk_session_save, diff --git a/src/pages/BnkExtract.tsx b/src/pages/BnkExtract.tsx index 8392255a..b39b98fe 100644 --- a/src/pages/BnkExtract.tsx +++ b/src/pages/BnkExtract.tsx @@ -4,10 +4,10 @@ the audio splitter overlay, the session / auto-extract / game-banks toolbar, the playback footer, per-format extract and the full context-menu + modal set. - Backend: BNK/WPK parsing, WEM decode and the external Wwise/vgmstream tooling - live behind the bnk_* / wwise_* / audio_* Tauri commands, wrapped by - ./bnkextract/utils/backend.ts. The audio splitter renders its waveform with - wavesurfer.js — see AudioSplitter.tsx. */ + Backend: BNK/WPK parsing, WEM decode and Wwise Vorbis encode all live behind + the bnk_* / audio_* Tauri commands, wrapped by ./bnkextract/utils/backend.ts. + Everything runs in-process — there is no external toolchain. The audio splitter + renders its waveform with wavesurfer.js — see AudioSplitter.tsx. */ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { Box } from '@mui/material'; @@ -19,7 +19,6 @@ import AutoExtractDialog from './bnkextract/components/AutoExtractDialog'; import AudioSplitter from './bnkextract/components/AudioSplitter'; import BnkMainContent from './bnkextract/components/BnkMainContent'; import BnkSettingsModal from './bnkextract/components/BnkSettingsModal'; -import BnkInstallModal from './bnkextract/components/BnkInstallModal'; import BnkConvertOverlay from './bnkextract/components/BnkConvertOverlay'; import BnkGainModal from './bnkextract/components/BnkGainModal'; import BnkContextMenu from './bnkextract/components/BnkContextMenu'; @@ -32,12 +31,11 @@ import LoadFromGameModal from './port/components/modals/PortDonorFromGameModal'; import type { BanksConfirmArgs } from './port/components/modals/donor/types'; import { - loadBanks, wemToPlayable, extractNodes, saveBank, checkWwiseInstalled, installWwise, - getModFiles, extractBnkBanksFromGame, loadCodebook, pickDirectory, + loadBanks, wemToPlayable, extractNodes, saveBank, + getModFiles, extractBnkBanksFromGame, pickDirectory, locateBanksForBin, convertToWem, convertWavsToWem, amplifyWem, silenceWem, readFileBytes, } from './bnkextract/utils/backend'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; import { useFileDrop, type FileDropPosition } from '@/lib/util/useFileDrop'; import { containerStyle, mainContentStyle, treeViewStyle, sidebarStyle, @@ -188,12 +186,8 @@ export function BnkExtract() { const [leftSortMode, setLeftSortMode] = useState('name-asc'); // ── Wwise conversion state ──────────────────────────────────────────────── - const [isWwiseInstalled, setIsWwiseInstalled] = useState(false); - const [showInstallModal, setShowInstallModal] = useState(false); const [showConvertOverlay, setShowConvertOverlay] = useState(false); const [convertStatus, setConvertStatus] = useState(''); - const [installProgress, setInstallProgress] = useState(''); - const [isInstalling, setIsInstalling] = useState(false); // ── History (undo/redo) ─────────────────────────────────────────────────── const [undoStack, setUndoStack] = useState([]); @@ -240,9 +234,7 @@ export function BnkExtract() { // Object URL of the clip currently loaded into `audioRef`, so it can be // revoked when playback stops or another clip replaces it. const playingUrlRef = useRef(null); - const codebookDataRef = useRef(null); - const pendingConversion = useRef<{ filePath: string; targetNodeId: string } | null>(null); const pendingGroupIds = useRef([]); @@ -294,8 +286,6 @@ export function BnkExtract() { // ── Codebook + Wwise availability (once) ────────────────────────────────── useEffect(() => { - void loadCodebook().then((cb) => { codebookDataRef.current = cb; }).catch(() => { }); - void checkWwiseInstalled().then(setIsWwiseInstalled).catch(() => { }); }, []); // ── Playback ────────────────────────────────────────────────────────────── @@ -325,7 +315,7 @@ export function BnkExtract() { const playable = browserPlayable(raw) ? raw // Decode the WEM to a playable OGG/WAV container in Rust. - : await wemToPlayable(raw, codebookDataRef.current); + : await wemToPlayable(raw); if (!playable || playable.length === 0) { setStatusMessage(`Cannot decode ${node.name} for playback`); return; @@ -376,6 +366,21 @@ export function BnkExtract() { if (typeof picked !== 'string') return; const setter = pane === 'right' ? setRightPaths : setLeftPaths; setter((prev) => ({ ...prev, [kind]: picked })); + + /* Picking the BIN is enough to find its banks: they share a skin number + and a mod root, which is the same pairing a folder drop resolves. Only + empty fields are filled, so an explicit choice is never overwritten. */ + if (kind !== 'bin') return; + const located = await locateBanksForBin(picked); + if (!located) return; + setter((prev) => ({ + ...prev, + wpk: prev.wpk || located.audio, + bnk: prev.bnk || located.events, + })); + if (located.audio) { + setStatusMessage(`Found ${located.events ? 'audio + events banks' : 'audio bank'}`); + } }, []); const handleSetPath = useCallback((pane: Pane, kind: keyof PathSet, value: string) => { @@ -577,7 +582,6 @@ export function BnkExtract() { const handleReplace = useCallback(async () => { if (!hasAudioSelection()) return; - if (!isWwiseInstalled) { setShowInstallModal(true); return; } const picked = await pickPath({ mode: 'file', filters: [{ name: 'Audio', extensions: ['wem', 'wav', 'ogg', 'mp3'] }], recentsKey: 'audio' }); if (typeof picked !== 'string') return; const targets = collectSelectedAudioNodes(); @@ -599,7 +603,7 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [hasAudioSelection, isWwiseInstalled, collectSelectedAudioNodes, activePane, pushToHistory, applyAudioToNodes]); + }, [hasAudioSelection, collectSelectedAudioNodes, activePane, pushToHistory, applyAudioToNodes]); const handleMakeSilent = useCallback((options?: { pane?: Pane; nodeIds?: string[] }) => { const pane = options?.pane ?? activePane; @@ -741,7 +745,6 @@ export function BnkExtract() { const applyExternalFiles = useCallback(async (files: { path: string; name: string }[], targetId: string, pane: Pane) => { const file = files.find((f) => /\.(wem|wav|ogg|mp3)$/i.test(f.name)); if (!file) return; - if (!file.name.toLowerCase().endsWith('.wem') && !isWwiseInstalled) { setShowInstallModal(true); return; } setShowConvertOverlay(true); setConvertStatus(`Converting ${file.name}...`); try { @@ -757,7 +760,7 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [isWwiseInstalled, pushToHistory, applyAudioToNodes]); + }, [pushToHistory, applyAudioToNodes]); /* External file→node drops are routed by the OS drag-drop listener (via cursor hit-test), so the DOM handler is a no-op. Internal node→node drags still flow @@ -810,8 +813,6 @@ export function BnkExtract() { const importReferenceFiles = useCallback(async (paths: string[]) => { const audio = paths.filter((p) => /\.(wem|wav|ogg|mp3)$/i.test(p)); if (audio.length === 0) return; - const nonWem = audio.some((p) => !p.toLowerCase().endsWith('.wem')); - if (nonWem && !isWwiseInstalled) { setShowInstallModal(true); return; } setShowConvertOverlay(true); const children: BnkNode[] = []; for (let i = 0; i < audio.length; i++) { @@ -839,7 +840,7 @@ export function BnkExtract() { setRightExpandedNodes((prev) => new Set(prev).add(group.id)); setActivePane('right'); setStatusMessage(`Imported ${children.length} reference file(s)`); - }, [isWwiseInstalled, pushToHistory]); + }, [pushToHistory]); /* The OS drag-drop listener imports the real paths; the DOM handler only clears the drag-over highlight. */ @@ -848,21 +849,6 @@ export function BnkExtract() { setRightPaneDragOver(false); }, []); - // ── Wwise install ───────────────────────────────────────────────────────── - const handleInstallWwise = useCallback(async () => { - setIsInstalling(true); - setInstallProgress('Installing Wwise tools...'); - const unlisten = await listen('wwise:install-progress', (e) => setInstallProgress(e.payload)); - try { - const res = await installWwise(); - if (res.success) { setIsWwiseInstalled(true); setShowInstallModal(false); setStatusMessage('Wwise tools installed'); } - else setInstallProgress(res.error || 'Install failed'); - } finally { - unlisten(); - setIsInstalling(false); - } - }, []); - // ── Gain ────────────────────────────────────────────────────────────────── /* Re-encode each targeted WEM with the chosen dB gain (decode → scale → Wwise). */ const handleApplyGain = useCallback(async () => { @@ -879,7 +865,6 @@ export function BnkExtract() { } const withData = targets.filter((n) => n.audioData?.data?.length); if (withData.length === 0) { setStatusMessage('No audio to amplify'); return; } - if (!isWwiseInstalled) { setShowInstallModal(true); return; } const db = parseFloat(gainDb); if (isNaN(db)) { setStatusMessage('Invalid gain value'); return; } @@ -910,17 +895,13 @@ export function BnkExtract() { } finally { setShowConvertOverlay(false); } - }, [gainDb, gainTargetPane, selectedNodes, rightSelectedNodes, treeData, rightTreeData, isWwiseInstalled, pushToHistory]); + }, [gainDb, gainTargetPane, selectedNodes, rightSelectedNodes, treeData, rightTreeData, pushToHistory]); // ── Splitter actions ────────────────────────────────────────────────────── const handleOpenInSplitter = useCallback(() => { const node = contextMenu?.node; const pane = contextMenu?.pane || activePane; handleCloseContextMenu(); - if (node?.audioData && !isWwiseInstalled) { - setShowInstallModal(true); - return; - } setSplitterInitialFile(node ? { nodeId: node.id, name: node.name, @@ -929,7 +910,7 @@ export function BnkExtract() { data: node.audioData?.data, } : null); setShowAudioSplitter(true); - }, [contextMenu, activePane, isWwiseInstalled, handleCloseContextMenu]); + }, [contextMenu, activePane, handleCloseContextMenu]); const handleSplitterReplace = useCallback((data: Uint8Array, nodeId: string, pane?: string) => { if (!data?.length || !nodeId) return; @@ -940,12 +921,8 @@ export function BnkExtract() { const handleSplitterExportSegments = useCallback(async (segments: SplitterSegment[]) => { if (segments.length === 0) return; - if (!isWwiseInstalled) { - throw new Error('Wwise tools are required to push segments to the reference pane'); - } - setShowConvertOverlay(true); - setConvertStatus(`Converting ${segments.length} segment(s) with Wwise...`); + setConvertStatus(`Converting ${segments.length} segment(s)...`); try { const timestamp = Date.now(); const converted = await convertWavsToWem(segments); @@ -1005,7 +982,7 @@ export function BnkExtract() { setShowConvertOverlay(false); setConvertStatus(''); } - }, [isWwiseInstalled, pushToHistory]); + }, [pushToHistory]); // ── Auto-extract / mod folder ───────────────────────────────────────────── /* Parse each scanned mod-file set into the left tree, then (if an output dir @@ -1347,14 +1324,6 @@ export function BnkExtract() { return ( - { setShowInstallModal(false); pendingConversion.current = null; }} - onInstall={handleInstallWwise} - /> - 0} onRemoveFromGroup={() => { pendingGroupIds.current = getContextTargetIds(); handleCloseContextMenu(); handleRemoveFromGroup(); }} showRemoveFromGroup={contextMenu?.pane === 'right' && !!contextMenu?.node?.id && isNodeInGroup(contextMenu.node.id, rightTreeData)} - isWwiseInstalled={isWwiseInstalled} /> void; showRemoveFromGroup: boolean; - isWwiseInstalled: boolean; } export default function BnkContextMenu({ @@ -39,7 +38,6 @@ export default function BnkContextMenu({ showAddToGroup, onRemoveFromGroup, showRemoveFromGroup, - isWwiseInstalled, }: Props) { return ( Make Silent , - + Adjust Volume... - {!isWwiseInstalled && needs tools} , - + Open in Audio Splitter... - {contextMenu?.node?.audioData && !isWwiseInstalled && needs tools} , , diff --git a/src/pages/bnkextract/components/BnkInstallModal.tsx b/src/pages/bnkextract/components/BnkInstallModal.tsx deleted file mode 100644 index 1eaf968b..00000000 --- a/src/pages/bnkextract/components/BnkInstallModal.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { LinearProgress } from '@mui/material'; - -interface Props { - open: boolean; - isInstalling: boolean; - installProgress: string; - onCancel: () => void; - onInstall: () => void; -} - -export default function BnkInstallModal({ - open, - isInstalling, - installProgress, - onCancel, - onInstall, -}: Props) { - if (!open) return null; - - return ( -
-
-
-

Audio Conversion Tools

-
- -
-

- Converting .wav / .mp3 / .ogg to WEM - requires the Wwise engine (~200 MB). Install it once to your AppData folder. -

- - {isInstalling && ( -
- - {installProgress} -
- )} -
- - {!isInstalling && ( -
- - -
- )} -
-
- ); -} diff --git a/src/pages/bnkextract/utils/backend.ts b/src/pages/bnkextract/utils/backend.ts index e99ee940..c1a72e05 100644 --- a/src/pages/bnkextract/utils/backend.ts +++ b/src/pages/bnkextract/utils/backend.ts @@ -1,13 +1,13 @@ /* BNK/WPK parse + WEM decode/encode backend. - Pure-Rust parsing/decoding plus the external Wwise/vgmstream tooling live behind - the bnk_* / wwise_* / audio_* Tauri commands. These wrappers keep stable - signatures so the rest of the UI is unaffected. */ + Everything is in-process behind the bnk_* / audio_* Tauri commands: containers + and Wwise Vorbis coding come from ritoshark::audio, and user mp3/flac/ogg is + decoded by symphonia. There is no external toolchain to install any more. */ import { invoke } from '@tauri-apps/api/core'; import { pickPath } from '@/components/explorer'; import { log } from '@/lib/util/logger'; -import type { AudioData, BnkNode, ExtractFormat } from '../types'; +import type { BnkNode, ExtractFormat } from '../types'; /* Tauri serializes Rust Vec as a JSON number array; rehydrate to bytes. */ function toBytes(value: unknown): Uint8Array { @@ -39,6 +39,7 @@ interface WireNode { name: string; audioData?: { id: number; data: number[] } | null; children?: WireNode[]; + originalPath?: string; } function toWireNode(node: BnkNode): WireNode { const wire: WireNode = { name: node.name }; @@ -46,6 +47,10 @@ function toWireNode(node: BnkNode): WireNode { wire.audioData = { id: node.audioData.id, data: Array.from(toBytes(node.audioData.data)) }; } if (node.children) wire.children = node.children.map(toWireNode); + /* Saving edits the container the tree came from instead of rebuilding one, + so the header and object hierarchy survive. The backend needs its path. */ + const source = node.originalPath ?? node.wpkPath ?? node.bnkPath; + if (source) wire.originalPath = source; return wire; } @@ -57,7 +62,6 @@ export interface LoadBanksArgs { export interface LoadBanksResult { tree: BnkNode; - audioFiles: AudioData[]; fileCount: number; type: string; } @@ -67,16 +71,12 @@ export async function loadBanks(args: LoadBanksArgs): Promise('bnk_load_banks', { args }); if (!result?.tree) return result; hydrateTree(result.tree); - result.audioFiles?.forEach((a) => { if (a.data) a.data = toBytes(a.data); }); return result; } /* Decode raw WEM bytes to a playable container (ogg/wav). Returns null on failure so the UI can fall back gracefully. */ -export async function wemToPlayable( - raw: Uint8Array, - _codebook: Uint8Array | null, -): Promise { +export async function wemToPlayable(raw: Uint8Array): Promise { try { return toBytes(await invoke('bnk_wem_to_ogg', { data: Array.from(raw) })); } catch (e) { @@ -86,13 +86,13 @@ export async function wemToPlayable( } /* Per-format conversions used by the extract pipeline. */ -export async function wemToWav(raw: Uint8Array, _codebook: Uint8Array | null): Promise { +export async function wemToWav(raw: Uint8Array): Promise { return toBytes(await invoke('bnk_wem_to_wav', { data: Array.from(raw) })); } -export async function wemToOgg(raw: Uint8Array, _codebook: Uint8Array | null): Promise { +export async function wemToOgg(raw: Uint8Array): Promise { return toBytes(await invoke('bnk_wem_to_ogg', { data: Array.from(raw) })); } -export async function wemToMp3(raw: Uint8Array, _codebook: Uint8Array | null, bitrate: number): Promise { +export async function wemToMp3(raw: Uint8Array, bitrate: number): Promise { return toBytes(await invoke('bnk_wem_to_mp3', { data: Array.from(raw), bitrate })); } @@ -113,21 +113,23 @@ export async function saveBank(root: BnkNode, outPath: string): Promise { await invoke('bnk_save_bank', { args: { root: toWireNode(root), outPath } }); } -/* Wwise / vgmstream tooling availability + install. */ -export async function checkWwiseInstalled(): Promise { +/* Given a skin BIN, find the audio + events banks that belong to it. Returns + null when nothing convincing sits nearby, so the fields are left alone. */ +export async function locateBanksForBin( + binPath: string, +): Promise<{ audio: string; events: string } | null> { try { - return await invoke('wwise_check'); - } catch { - return false; - } -} -export async function installWwise(): Promise<{ success: boolean; error?: string }> { - try { - return await invoke<{ success: boolean; error?: string }>('wwise_install'); + return await invoke<{ audio: string; events: string } | null>( + 'bnk_locate_banks_for_bin', + { binPath }, + ); } catch (e) { - return { success: false, error: (e as Error).message }; + log.error('[BnkExtract] locateBanksForBin failed', e); + return null; } } + +/* Encoding is in-process now — no external toolchain to check for or install. */ export async function convertToWem(inputPath: string): Promise { return toBytes(await invoke('audio_convert_to_wem', { inputPath })); } @@ -205,15 +207,6 @@ export function silenceWem(): Uint8Array { return bytes; } -/* Codebook used by the WEM decoder, loaded once at mount. */ -export async function loadCodebook(): Promise { - try { - return toBytes(await invoke('bnk_load_codebook')); - } catch { - return null; - } -} - /* Dialog helpers (these DO work today via the Tauri dialog plugin). */ export async function pickFile(name: string, extensions: string[], multiple = false): Promise { try {