From 1adef41c9d15d0589abbf96513c3c009117e9603 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Tue, 12 May 2026 21:31:05 +0100 Subject: [PATCH 1/5] Added Bit read / write stuff --- .../Shared.ByteParsing/BitChunk.cs | 61 +++++++++++++++++ .../Shared.ByteParsing/BitHelper.cs | 59 ++++++++++++++++ .../Shared.ByteParsing/BitWriter.cs | 68 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 Shared/ByteParsing/Shared.ByteParsing/BitChunk.cs create mode 100644 Shared/ByteParsing/Shared.ByteParsing/BitHelper.cs create mode 100644 Shared/ByteParsing/Shared.ByteParsing/BitWriter.cs diff --git a/Shared/ByteParsing/Shared.ByteParsing/BitChunk.cs b/Shared/ByteParsing/Shared.ByteParsing/BitChunk.cs new file mode 100644 index 000000000..f386ea8f8 --- /dev/null +++ b/Shared/ByteParsing/Shared.ByteParsing/BitChunk.cs @@ -0,0 +1,61 @@ +namespace Shared.ByteParsing +{ + public class BitChunk + { + private readonly byte[] _buffer; + + public BitChunk(byte[] buffer) + { + _buffer = buffer; + } + + public int BitPosition { get; private set; } + + public uint ReadBits(int bitCount) + { + if (bitCount == 0) + return 0; + + if (bitCount is < 0 or > 32) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (BitPosition + bitCount > BitHelper.BytesToBits(_buffer.Length)) + throw new EndOfStreamException("Attempted to read past the end of the bitstream."); + + uint value = 0; + for (var bitIndex = 0; bitIndex < bitCount; bitIndex++) + { + var absoluteBitIndex = BitPosition + bitIndex; + var byteIndex = BitHelper.ByteIndexFromBitPosition(absoluteBitIndex); + var bitInByte = BitHelper.BitIndexInByte(absoluteBitIndex); + var bitValue = (_buffer[byteIndex] >> bitInByte) & 1; + value |= (uint)bitValue << bitIndex; + } + + BitPosition += bitCount; + return value; + } + + public byte[] ReadBitRangeAsBytes(int startBit, int bitCount) + { + if (bitCount < 0) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (startBit < 0 || startBit + bitCount > BitHelper.BytesToBits(_buffer.Length)) + throw new ArgumentOutOfRangeException(nameof(startBit)); + + var output = new byte[BitHelper.BitsToBytes(bitCount)]; + for (var bitIndex = 0; bitIndex < bitCount; bitIndex++) + { + var sourceBit = startBit + bitIndex; + var sourceByte = BitHelper.ByteIndexFromBitPosition(sourceBit); + var sourceShift = BitHelper.BitIndexInByte(sourceBit); + var bitValue = (_buffer[sourceByte] >> sourceShift) & 1; + if (bitValue != 0) + output[BitHelper.ByteIndexFromBitPosition(bitIndex)] |= (byte)(1 << BitHelper.BitIndexInByte(bitIndex)); + } + + return output; + } + } +} diff --git a/Shared/ByteParsing/Shared.ByteParsing/BitHelper.cs b/Shared/ByteParsing/Shared.ByteParsing/BitHelper.cs new file mode 100644 index 000000000..3e1bf942c --- /dev/null +++ b/Shared/ByteParsing/Shared.ByteParsing/BitHelper.cs @@ -0,0 +1,59 @@ +namespace Shared.ByteParsing +{ + public static class BitHelper + { + public const int BitsPerByte = 8; + + public static int ByteIndexFromBitPosition(int bitPosition) => bitPosition / BitsPerByte; + + public static int BitIndexInByte(int bitPosition) => bitPosition % BitsPerByte; + + public static int BitsToBytes(int bitCount) => (bitCount + BitsPerByte - 1) / BitsPerByte; + + public static int BytesToBits(int byteCount) => byteCount * BitsPerByte; + + public static int ExtractBits(byte value, int bitOffset, int bitCount) + { + if (bitOffset is < 0 or > 7) + throw new ArgumentOutOfRangeException(nameof(bitOffset)); + + if (bitCount is < 0 or > BitsPerByte) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (bitOffset + bitCount > BitsPerByte) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (bitCount == 0) + return 0; + + var mask = (1 << bitCount) - 1; + return (value >> bitOffset) & mask; + } + + public static uint ExtractBits(uint value, int bitOffset, int bitCount) + { + if (bitOffset is < 0 or > 31) + throw new ArgumentOutOfRangeException(nameof(bitOffset)); + + if (bitCount is < 0 or > 32) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (bitOffset + bitCount > 32) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + if (bitCount == 0) + return 0; + + var mask = bitCount == 32 ? uint.MaxValue : (1u << bitCount) - 1u; + return (value >> bitOffset) & mask; + } + + public static bool IsBitSet(int value, int bitIndex) + { + if (bitIndex is < 0 or > 31) + throw new ArgumentOutOfRangeException(nameof(bitIndex)); + + return (value & (1 << bitIndex)) != 0; + } + } +} \ No newline at end of file diff --git a/Shared/ByteParsing/Shared.ByteParsing/BitWriter.cs b/Shared/ByteParsing/Shared.ByteParsing/BitWriter.cs new file mode 100644 index 000000000..f028feede --- /dev/null +++ b/Shared/ByteParsing/Shared.ByteParsing/BitWriter.cs @@ -0,0 +1,68 @@ +namespace Shared.ByteParsing +{ + public class BitWriter + { + private byte[] _buffer; + + public BitWriter(int initialCapacity) + { + _buffer = new byte[initialCapacity]; + } + + public int BitPosition { get; private set; } + + public void WriteAscii(string value) + { + foreach (var character in value) + WriteByte((byte)character); + } + + public void WriteByte(byte value) => WriteBits(value, BitHelper.BitsPerByte); + + public void WriteBits(uint value, int bitCount) + { + if (bitCount == 0) + return; + + if (bitCount is < 0 or > 32) + throw new ArgumentOutOfRangeException(nameof(bitCount)); + + EnsureCapacity(BitPosition + bitCount); + for (var bitIndex = 0; bitIndex < bitCount; bitIndex++) + { + var absoluteBitIndex = BitPosition + bitIndex; + var byteIndex = BitHelper.ByteIndexFromBitPosition(absoluteBitIndex); + var bitInByte = BitHelper.BitIndexInByte(absoluteBitIndex); + var bitValue = (int)((value >> bitIndex) & 1U); + if (bitValue != 0) + _buffer[byteIndex] |= (byte)(1 << bitInByte); + } + + BitPosition += bitCount; + } + + public void AlignToByte() + { + var remainder = BitHelper.BitIndexInByte(BitPosition); + if (remainder != 0) + WriteBits(0, BitHelper.BitsPerByte - remainder); + } + + public byte[] ToArray() + { + var byteLength = BitHelper.BitsToBytes(BitPosition); + var copy = new byte[byteLength]; + Buffer.BlockCopy(_buffer, 0, copy, 0, byteLength); + return copy; + } + + private void EnsureCapacity(int requiredBits) + { + var requiredBytes = BitHelper.BitsToBytes(requiredBits); + if (requiredBytes <= _buffer.Length) + return; + + Array.Resize(ref _buffer, Math.Max(requiredBytes, _buffer.Length * 2)); + } + } +} From 07e328b10bb5170d90fe4f0afc798a171cac202f Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Tue, 12 May 2026 21:31:23 +0100 Subject: [PATCH 2/5] Added native WAV --- Shared/GameFiles/Audio/Codecs/PcmAudio.cs | 14 +++ Shared/GameFiles/Audio/Wav/DataChunk.cs | 25 ++++ Shared/GameFiles/Audio/Wav/FmtChunk.cs | 49 ++++++++ Shared/GameFiles/Audio/Wav/RiffChunk.cs | 31 +++++ Shared/GameFiles/Audio/Wav/WavChunkFactory.cs | 15 +++ Shared/GameFiles/Audio/Wav/WavChunkHeader.cs | 44 +++++++ Shared/GameFiles/Audio/Wav/WavFile.cs | 119 ++++++++++++++++++ Shared/GameFiles/Audio/Wav/WavFileHeader.cs | 56 +++++++++ 8 files changed, 353 insertions(+) create mode 100644 Shared/GameFiles/Audio/Codecs/PcmAudio.cs create mode 100644 Shared/GameFiles/Audio/Wav/DataChunk.cs create mode 100644 Shared/GameFiles/Audio/Wav/FmtChunk.cs create mode 100644 Shared/GameFiles/Audio/Wav/RiffChunk.cs create mode 100644 Shared/GameFiles/Audio/Wav/WavChunkFactory.cs create mode 100644 Shared/GameFiles/Audio/Wav/WavChunkHeader.cs create mode 100644 Shared/GameFiles/Audio/Wav/WavFile.cs create mode 100644 Shared/GameFiles/Audio/Wav/WavFileHeader.cs diff --git a/Shared/GameFiles/Audio/Codecs/PcmAudio.cs b/Shared/GameFiles/Audio/Codecs/PcmAudio.cs new file mode 100644 index 000000000..ef0aaf900 --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/PcmAudio.cs @@ -0,0 +1,14 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Codecs +{ + public class PcmAudio + { + public ushort BitsPerSample { get; set; } + public ushort Channels { get; set; } + public byte[] Data { get; set; } = []; + public uint SampleRate { get; set; } + + public int SampleCount => Data.Length / (BitsPerSample / BitHelper.BitsPerByte) / Channels; + } +} diff --git a/Shared/GameFiles/Audio/Wav/DataChunk.cs b/Shared/GameFiles/Audio/Wav/DataChunk.cs new file mode 100644 index 000000000..bc5d76e3f --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/DataChunk.cs @@ -0,0 +1,25 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Wav +{ + public class DataChunk : RiffChunk + { + public const string ChunkTag = "data"; + public byte[] Data { get; set; } = []; + + public DataChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + Data = chunk.ReadBytes(chunk.BytesLeft); + } + + public override byte[] WriteData() + { + return Data; + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/FmtChunk.cs b/Shared/GameFiles/Audio/Wav/FmtChunk.cs new file mode 100644 index 000000000..7c8ffa9be --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/FmtChunk.cs @@ -0,0 +1,49 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Wav +{ + public class FmtChunk : RiffChunk + { + public const int ChunkSize = 16; + public const string ChunkTag = "fmt "; + public const ushort PcmFormatTag = 1; + + public int Size { get; set; } = ChunkSize; + public ushort FormatTag { get; set; } = PcmFormatTag; + public ushort Channels { get; set; } + public uint SampleRate { get; set; } + public uint ByteRate { get; set; } + public ushort BlockAlign { get; set; } + public ushort BitsPerSample { get; set; } + + public FmtChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < ChunkSize) + throw new InvalidDataException($"WAV fmt chunk must be at least {ChunkSize} bytes."); + + FormatTag = chunk.ReadUShort(); + Channels = chunk.ReadUShort(); + SampleRate = chunk.ReadUInt32(); + ByteRate = chunk.ReadUInt32(); + BlockAlign = chunk.ReadUShort(); + BitsPerSample = chunk.ReadUShort(); + } + + public override byte[] WriteData() + { + using var stream = new MemoryStream(ChunkSize); + stream.Write(ByteParsers.UShort.EncodeValue(FormatTag, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(Channels, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SampleRate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(ByteRate, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(BlockAlign, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(BitsPerSample, out _)); + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/RiffChunk.cs b/Shared/GameFiles/Audio/Wav/RiffChunk.cs new file mode 100644 index 000000000..765f565e7 --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/RiffChunk.cs @@ -0,0 +1,31 @@ +using Serilog; +using Shared.ByteParsing; +using Shared.Core.ErrorHandling; + +namespace Shared.GameFormats.Audio.Wav +{ + public abstract class RiffChunk + { + readonly ILogger _logger = Logging.Create(); + + public string Tag { get; set; } = string.Empty; + public uint ByteIndexInFile { get; set; } + + public abstract void ReadData(ByteChunk chunk); + + public abstract byte[] WriteData(); + + public void ReadChunk(ByteChunk chunk) + { + try + { + ReadData(chunk); + } + catch (Exception e) + { + _logger.Here().Error($"Failed to parse chunk '{Tag}' at byte index {ByteIndexInFile} - " + e.Message); + throw; + } + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/WavChunkFactory.cs b/Shared/GameFiles/Audio/Wav/WavChunkFactory.cs new file mode 100644 index 000000000..203289779 --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/WavChunkFactory.cs @@ -0,0 +1,15 @@ +namespace Shared.GameFormats.Audio.Wav +{ + public static class WavChunkFactory + { + public static RiffChunk? CreateChunk(string tag) + { + return tag switch + { + FmtChunk.ChunkTag => new FmtChunk(), + DataChunk.ChunkTag => new DataChunk(), + _ => null, + }; + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/WavChunkHeader.cs b/Shared/GameFiles/Audio/Wav/WavChunkHeader.cs new file mode 100644 index 000000000..3284a3e4c --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/WavChunkHeader.cs @@ -0,0 +1,44 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Wav +{ + public class WavChunkHeader + { + public const int HeaderSize = 8; + public const int ChunkPaddingAlignment = 2; + + public string Tag { get; set; } = string.Empty; + public int ChunkSize { get; set; } + + public static WavChunkHeader ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < HeaderSize) + throw new InvalidDataException($"WAV chunk header must be at least {HeaderSize} bytes."); + + var tag = Encoding.UTF8.GetString(chunk.ReadBytes(4)); + var size = chunk.ReadInt32(); + + if (size < 0 || size > chunk.BytesLeft) + throw new InvalidDataException("WAV chunk extends beyond the end of the file."); + + return new WavChunkHeader + { + Tag = tag, + ChunkSize = size, + }; + } + + public static void WriteData(Stream stream, WavChunkHeader header) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(header); + + if (header.Tag.Length != 4) + throw new InvalidDataException($"WAV chunk tag must be 4 bytes, got '{header.Tag}'."); + + stream.Write(Encoding.ASCII.GetBytes(header.Tag)); + stream.Write(ByteParsers.Int32.EncodeValue(header.ChunkSize, out _)); + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/WavFile.cs b/Shared/GameFiles/Audio/Wav/WavFile.cs new file mode 100644 index 000000000..97cd98715 --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/WavFile.cs @@ -0,0 +1,119 @@ +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Codecs; + +namespace Shared.GameFormats.Audio.Wav +{ + public class WavFile + { + private const int StandardPcmWavHeaderSize = 44; + + public WavFileHeader Header { get; set; } = new(); + public FmtChunk FmtChunk { get; set; } = new(); + public DataChunk DataChunk { get; set; } = new(); + public PcmAudio Audio { get; set; } = new(); + + public static WavFile CreateFromBytes(byte[] wavData) + { + ArgumentNullException.ThrowIfNull(wavData); + var wavFile = new WavFile(); + wavFile.ReadData(new ByteChunk(wavData)); + return wavFile; + } + + public void ReadData(ByteChunk chunk) + { + Header = WavFileHeader.ReadData(chunk); + + FmtChunk? fmtChunk = null; + DataChunk? dataChunk = null; + + while (chunk.BytesLeft >= WavChunkHeader.HeaderSize) + { + var chunkStartIndex = chunk.Index; + var chunkHeader = WavChunkHeader.ReadData(chunk); + var chunkData = new ByteChunk(chunk.ReadBytes(chunkHeader.ChunkSize)); + + var wavChunk = WavChunkFactory.CreateChunk(chunkHeader.Tag); + if (wavChunk != null) + { + wavChunk.ByteIndexInFile = (uint)chunkStartIndex; + wavChunk.ReadChunk(chunkData); + + if (chunkHeader.Tag == FmtChunk.ChunkTag) + { + if (wavChunk is FmtChunk fmt) + fmtChunk = fmt; + } + else if (chunkHeader.Tag == DataChunk.ChunkTag) + { + if (wavChunk is DataChunk data) + dataChunk = data; + } + } + + // Padding + if (chunkHeader.ChunkSize % WavChunkHeader.ChunkPaddingAlignment != 0 && chunk.BytesLeft > 0) + chunk.Advance(1); + } + + if (fmtChunk == null || dataChunk == null) + throw new InvalidDataException("WAV file is missing required fmt or data chunk."); + + if (fmtChunk.FormatTag == 0 || fmtChunk.Channels <= 0 || fmtChunk.SampleRate <= 0 || + fmtChunk.BitsPerSample <= 0 || dataChunk.Data.Length == 0) + throw new InvalidDataException("WAV file has invalid fmt or data chunk information."); + + FmtChunk = fmtChunk; + DataChunk = dataChunk; + Audio = new PcmAudio + { + BitsPerSample = fmtChunk.BitsPerSample, + Channels = fmtChunk.Channels, + Data = dataChunk.Data, + SampleRate = fmtChunk.SampleRate, + }; + } + + public byte[] WriteData() + { + ArgumentNullException.ThrowIfNull(Audio); + + FmtChunk.BitsPerSample = Audio.BitsPerSample; + FmtChunk.Channels = Audio.Channels; + FmtChunk.SampleRate = Audio.SampleRate; + FmtChunk.FormatTag = FmtChunk.PcmFormatTag; + DataChunk.Data = Audio.Data; + + var bitsPerSample = Audio.BitsPerSample; + var blockAlign = checked((ushort)(Audio.Channels * (bitsPerSample / BitHelper.BitsPerByte))); + var byteRate = checked(Audio.SampleRate * blockAlign); + var bytesBeforeSize = WavFileHeader.BytesBeforeSize; + var riffChunkSize = StandardPcmWavHeaderSize - bytesBeforeSize + DataChunk.Data.Length; + + Header.ContainerId = WavFileHeader.RiffContainerId; + Header.FormType = WavFileHeader.WaveFormType; + Header.RiffChunkSize = riffChunkSize; + + FmtChunk.Size = FmtChunk.ChunkSize; + FmtChunk.ByteRate = byteRate; + FmtChunk.BlockAlign = blockAlign; + + var fmtData = FmtChunk.WriteData(); + var dataData = DataChunk.WriteData(); + + using var stream = new MemoryStream(StandardPcmWavHeaderSize + dataData.Length); + + WavFileHeader.WriteData(stream, Header); + + var fmtChunkHeader = new WavChunkHeader { Tag = FmtChunk.ChunkTag, ChunkSize = fmtData.Length }; + WavChunkHeader.WriteData(stream, fmtChunkHeader); + stream.Write(fmtData); + + var dataChunkHeader = new WavChunkHeader { Tag = DataChunk.ChunkTag, ChunkSize = dataData.Length }; + WavChunkHeader.WriteData(stream, dataChunkHeader); + stream.Write(dataData); + + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/WavFileHeader.cs b/Shared/GameFiles/Audio/Wav/WavFileHeader.cs new file mode 100644 index 000000000..941fe2801 --- /dev/null +++ b/Shared/GameFiles/Audio/Wav/WavFileHeader.cs @@ -0,0 +1,56 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Wav +{ + public class WavFileHeader + { + public const int Size = 12; + public const int BytesBeforeSize = 8; + public const string RiffContainerId = "RIFF"; + public const string WaveFormType = "WAVE"; + + public string ContainerId { get; set; } = RiffContainerId; + public int RiffChunkSize { get; set; } + public string FormType { get; set; } = WaveFormType; + + public static WavFileHeader ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < Size) + throw new InvalidDataException($"WAV file header must be at least {Size} bytes."); + + var containerId = Encoding.ASCII.GetString(chunk.ReadBytes(4)); + if (containerId != RiffContainerId) + throw new InvalidDataException($"Expected RIFF container ID '{RiffContainerId}', got '{containerId}'."); + + var riffSize = chunk.ReadInt32(); + + var formType = Encoding.ASCII.GetString(chunk.ReadBytes(4)); + if (formType != WaveFormType) + throw new InvalidDataException($"Expected WAVE form type '{WaveFormType}', got '{formType}'."); + + return new WavFileHeader + { + ContainerId = containerId, + RiffChunkSize = riffSize, + FormType = formType, + }; + } + + public static void WriteData(Stream stream, WavFileHeader header) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(header); + + if (header.ContainerId.Length != 4) + throw new InvalidDataException($"RIFF container id must be 4 bytes, got '{header.ContainerId}'."); + + if (header.FormType.Length != 4) + throw new InvalidDataException($"RIFF form type must be 4 bytes, got '{header.FormType}'."); + + stream.Write(Encoding.ASCII.GetBytes(header.ContainerId)); + stream.Write(ByteParsers.Int32.EncodeValue(header.RiffChunkSize, out _)); + stream.Write(Encoding.ASCII.GetBytes(header.FormType)); + } + } +} From c3d3bb039419976032c3a708379d3450e8eb6850 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Tue, 12 May 2026 21:33:19 +0100 Subject: [PATCH 3/5] Added native WEM (Vorbis, Wwise V132+) --- Shared/EmbeddedResources/ResourceLoader.cs | 8 + .../Wwise/packed_codebooks_aoTuV_603.bin | Bin 0 -> 74387 bytes .../Shared.EmbeddedResources.csproj | 2 + Shared/GameFiles/Audio/Codecs/VorbisAudio.cs | 133 ++++ .../Audio/Codecs/VorbisAudioPacket.cs | 8 + .../GameFiles/Audio/Codecs/VorbisHeaders.cs | 9 + Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs | 10 + Shared/GameFiles/Audio/Ogg/OggSerialiser.cs | 112 +++ Shared/GameFiles/Shared.GameFormats.csproj | 5 + .../V136/Shared/AdvSettingsParams_V136.cs | 10 + .../Hirc/V136/Shared/AkPluginParam_V136.cs | 8 +- Shared/GameFiles/Wwise/Wem/V132/AkdChunk.cs | 157 ++++ Shared/GameFiles/Wwise/Wem/V132/CueChunk.cs | 39 + Shared/GameFiles/Wwise/Wem/V132/CuePoint.cs | 36 + Shared/GameFiles/Wwise/Wem/V132/DataChunk.cs | 66 ++ .../Wem/V132/Decoding/VorbisCommentHeader.cs | 34 + .../Wem/V132/Decoding/VorbisDecodeResult.cs | 15 + .../Decoding/VorbisIdentificationHeader.cs | 37 + .../Wem/V132/Decoding/WemVorbisDecoder.cs | 275 +++++++ .../Wem/V132/Encoding/BiquadCoefficients.cs | 4 + .../V132/Encoding/KWeightingCoefficients.cs | 4 + .../Wwise/Wem/V132/Encoding/VorbisCodebook.cs | 15 + .../V132/Encoding/VorbisModeConfiguration.cs | 8 + .../Wwise/Wem/V132/Encoding/WemAudioPacket.cs | 28 + .../Wem/V132/Encoding/WemEncodingSettings.cs | 14 + .../Wem/V132/Encoding/WemSeekTableRecord.cs | 29 + .../Wem/V132/Encoding/WemVorbisEncoder.cs | 712 ++++++++++++++++++ .../Wem/V132/Encoding/WemVorbisSetupData.cs | 8 + .../Wem/V132/Encoding/WwiseCodebookLibrary.cs | 152 ++++ Shared/GameFiles/Wwise/Wem/V132/FmtChunk.cs | 133 ++++ Shared/GameFiles/Wwise/Wem/V132/JunkChunk.cs | 33 + Shared/GameFiles/Wwise/Wem/V132/RiffChunk.cs | 35 + .../Wwise/Wem/V132/RiffChunkFactory.cs | 28 + .../Wwise/Wem/V132/RiffChunkHeader.cs | 43 ++ .../GameFiles/Wwise/Wem/V132/UnknownChunk.cs | 16 + Shared/GameFiles/Wwise/Wem/V132/WemFile.cs | 173 +++++ .../GameFiles/Wwise/Wem/V132/WemFileHeader.cs | 49 ++ .../V132/WwiseVorbisBitstreamTranscriber.cs | 369 +++++++++ Shared/GameFiles/Wwise/WwiseHash.cs | 25 + 39 files changed, 2838 insertions(+), 4 deletions(-) create mode 100644 Shared/EmbeddedResources/Resources/Wwise/packed_codebooks_aoTuV_603.bin create mode 100644 Shared/GameFiles/Audio/Codecs/VorbisAudio.cs create mode 100644 Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs create mode 100644 Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs create mode 100644 Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs create mode 100644 Shared/GameFiles/Audio/Ogg/OggSerialiser.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/AkdChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/CueChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/CuePoint.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/DataChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisDecodeResult.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/BiquadCoefficients.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/KWeightingCoefficients.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisCodebook.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisModeConfiguration.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WemAudioPacket.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WemEncodingSettings.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WemSeekTableRecord.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisSetupData.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/Encoding/WwiseCodebookLibrary.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/FmtChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/JunkChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/RiffChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/RiffChunkFactory.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/RiffChunkHeader.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/UnknownChunk.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/WemFile.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/WemFileHeader.cs create mode 100644 Shared/GameFiles/Wwise/Wem/V132/WwiseVorbisBitstreamTranscriber.cs create mode 100644 Shared/GameFiles/Wwise/WwiseHash.cs diff --git a/Shared/EmbeddedResources/ResourceLoader.cs b/Shared/EmbeddedResources/ResourceLoader.cs index b80e9f2a9..12cfa7344 100644 --- a/Shared/EmbeddedResources/ResourceLoader.cs +++ b/Shared/EmbeddedResources/ResourceLoader.cs @@ -23,6 +23,14 @@ public static string[] LoadStringArray(string resourcePath) .Split(Environment.NewLine.ToCharArray(), StringSplitOptions.RemoveEmptyEntries); } + public static byte[] LoadBytes(string resourcePath) + { + using var resourceStream = GetResourceStream(resourcePath); + using var memoryStream = new MemoryStream(); + resourceStream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + public static BitmapImage LoadBitmapImage(string resourcePath) { using var stream = GetResourceStream(resourcePath); diff --git a/Shared/EmbeddedResources/Resources/Wwise/packed_codebooks_aoTuV_603.bin b/Shared/EmbeddedResources/Resources/Wwise/packed_codebooks_aoTuV_603.bin new file mode 100644 index 0000000000000000000000000000000000000000..a405d061b50cd793e17ad99b1f9f809ee372812a GIT binary patch literal 74387 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 literal 0 HcmV?d00001 diff --git a/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj b/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj index c13a0aa62..f1cdab685 100644 --- a/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj +++ b/Shared/EmbeddedResources/Shared.EmbeddedResources.csproj @@ -113,6 +113,7 @@ + @@ -164,6 +165,7 @@ + diff --git a/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs b/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs new file mode 100644 index 000000000..c90b5d1d7 --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs @@ -0,0 +1,133 @@ +using NVorbis; +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Ogg; + +namespace Shared.GameFormats.Audio.Codecs +{ + public class VorbisAudio + { + private const byte XiphLacingHeaderByte = 0x02; + private const int XiphLacingContinuationByte = 255; + private const float MinPcmSampleValue = -1.0f; + private const float MaxPcmSampleValue = 1.0f; + private const int PcmBitsPerSample = 16; + private const int PcmSampleReadBufferSize = 4096; + private const int OggSerialNumber = 1; + private const int VorbisHeaderPacketCount = 3; + private const double MillisecondsPerSecond = 1000.0; + + public byte Channels { get; set; } + public byte[] VorbisCodecPrivateData { get; set; } = []; + public List Packets { get; set; } = []; + public int SampleCount { get; set; } + public uint SampleRate { get; set; } + + public PcmAudio ToPcm() + { + var oggData = ToOgg(); + using var oggStream = new MemoryStream(oggData, writable: false); + using var vorbisReader = new VorbisReader(oggStream, closeOnDispose: false); + + var channels = vorbisReader.Channels; + var sampleRate = vorbisReader.SampleRate; + var sampleReadBuffer = new float[PcmSampleReadBufferSize * channels]; + using var audioDataStream = new MemoryStream(); + + int samplesRead; + while ((samplesRead = vorbisReader.ReadSamples(sampleReadBuffer, 0, sampleReadBuffer.Length)) > 0) + { + for (var sampleIndex = 0; sampleIndex < samplesRead; sampleIndex++) + { + var clampedSample = Math.Clamp(sampleReadBuffer[sampleIndex], MinPcmSampleValue, MaxPcmSampleValue); + var pcmSample = (short)Math.Round(clampedSample * short.MaxValue); + audioDataStream.WriteByte((byte)BitHelper.ExtractBits((uint)pcmSample, 0, BitHelper.BitsPerByte)); + audioDataStream.WriteByte((byte)BitHelper.ExtractBits((uint)pcmSample, BitHelper.BitsPerByte, BitHelper.BitsPerByte)); + } + } + + return new PcmAudio + { + BitsPerSample = PcmBitsPerSample, + Channels = (ushort)channels, + Data = audioDataStream.ToArray(), + SampleRate = (uint)sampleRate, + }; + } + + public byte[] ToOgg() + { + var headerPackets = ParseXiphLacedVorbisHeaders(VorbisCodecPrivateData); + var allPackets = new List(VorbisHeaderPacketCount + Packets.Count) + { + new() { PacketData = headerPackets.Identification, GranulePosition = 0, IsBeginningOfStream = true, IsEndOfStream = false }, + new() { PacketData = headerPackets.Comment, GranulePosition = 0, IsBeginningOfStream = false, IsEndOfStream = false }, + new() { PacketData = headerPackets.Setup, GranulePosition = 0, IsBeginningOfStream = false, IsEndOfStream = false } + }; + + for (var packetIndex = 0; packetIndex < Packets.Count; packetIndex++) + { + var packetData = Packets[packetIndex].Data; + long nextTimestampMilliseconds; + if (packetIndex + 1 < Packets.Count) + nextTimestampMilliseconds = Packets[packetIndex + 1].TimestampMilliseconds; + else + nextTimestampMilliseconds = (long)Math.Round(SampleCount * MillisecondsPerSecond / SampleRate); + + var granulePosition = (long)Math.Round(nextTimestampMilliseconds * SampleRate / MillisecondsPerSecond); + if (packetIndex == Packets.Count - 1) + granulePosition = SampleCount; + + allPackets.Add(new OggAudioPacket + { + PacketData = packetData, + GranulePosition = Math.Max(0, granulePosition), + IsBeginningOfStream = false, + IsEndOfStream = packetIndex == Packets.Count - 1, + }); + } + + return OggSerialiser.WritePackets(allPackets, OggSerialNumber); + } + + private static VorbisHeaders ParseXiphLacedVorbisHeaders(byte[] codecPrivate) + { + var chunk = new ByteChunk(codecPrivate); + + if (chunk.ReadByte() != XiphLacingHeaderByte) + throw new InvalidDataException("Vorbis codec private data is missing the expected Xiph lacing header."); + + var identificationPacketSize = ReadXiphLacedSize(chunk); + var commentPacketSize = ReadXiphLacedSize(chunk); + var setupPacketSize = chunk.BytesLeft - identificationPacketSize - commentPacketSize; + + if (identificationPacketSize <= 0 || commentPacketSize <= 0 || setupPacketSize <= 0) + throw new InvalidDataException("Vorbis codec private packet sizes are invalid."); + + var identificationPacket = chunk.ReadBytes(identificationPacketSize); + var commentPacket = chunk.ReadBytes(commentPacketSize); + var setupPacket = chunk.ReadBytes(setupPacketSize); + + return new VorbisHeaders + { + Identification = identificationPacket, + Comment = commentPacket, + Setup = setupPacket, + }; + } + + private static int ReadXiphLacedSize(ByteChunk chunk) + { + var size = 0; + while (chunk.BytesLeft > 0) + { + var value = chunk.ReadByte(); + size += value; + if (value != XiphLacingContinuationByte) + return size; + } + + throw new InvalidDataException("Unexpected end of Xiph lacing size encoding."); + } + + } +} diff --git a/Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs b/Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs new file mode 100644 index 000000000..06e79bedd --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs @@ -0,0 +1,8 @@ +namespace Shared.GameFormats.Audio.Codecs +{ + public class VorbisAudioPacket + { + public byte[] Data { get; set; } = []; + public long TimestampMilliseconds { get; set; } + } +} diff --git a/Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs b/Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs new file mode 100644 index 000000000..a6aeffbb0 --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs @@ -0,0 +1,9 @@ +namespace Shared.GameFormats.Audio.Codecs +{ + public class VorbisHeaders + { + public byte[] Identification { get; set; } = []; + public byte[] Comment { get; set; } = []; + public byte[] Setup { get; set; } = []; + } +} diff --git a/Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs b/Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs new file mode 100644 index 000000000..ece7c2851 --- /dev/null +++ b/Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs @@ -0,0 +1,10 @@ +namespace Shared.GameFormats.Audio.Ogg +{ + public class OggAudioPacket + { + public byte[] PacketData { get; set; } = []; + public long GranulePosition { get; set; } + public bool IsBeginningOfStream { get; set; } + public bool IsEndOfStream { get; set; } + } +} diff --git a/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs b/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs new file mode 100644 index 000000000..7c3734831 --- /dev/null +++ b/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs @@ -0,0 +1,112 @@ +using System.Buffers.Binary; +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Ogg +{ + public static class OggSerialiser + { + private const int OggMaxSegmentSize = 255; + private const int OggPageHeaderBaseSize = 27; + private const int OggBeginningOfStreamFlag = 0x02; + private const int OggEndOfStreamFlag = 0x04; + private const int OggCrcFieldByteOffset = 22; + private const int OggCrc32TableSize = 256; + private const uint OggCrc32Polynomial = 0x04C11DB7u; + private const int OggCrc32TopByteShift = 24; + private const int OggCrc32TopBitIndex = 31; + + private static readonly uint[] s_oggCyclicRedundancyCheckTable = BuildOggCyclicRedundancyCheckTable(); + + public static byte[] WritePackets(List packets, int serialNumber) + { + using var output = new MemoryStream(); + var sequenceNumber = 0; + foreach (var packet in packets) + { + WriteOggPage(output, packet, serialNumber, sequenceNumber); + sequenceNumber++; + } + + return output.ToArray(); + } + + private static void WriteOggPage(Stream output, OggAudioPacket packet, int serialNumber, int sequenceNumber) + { + var packetLength = packet.PacketData.Length; + var segmentCount = packetLength / OggMaxSegmentSize + 1; + if (segmentCount > OggMaxSegmentSize) + throw new InvalidDataException("Vorbis packet is too large for a single Ogg page."); + + var headerType = 0; + if (packet.IsBeginningOfStream) + headerType |= OggBeginningOfStreamFlag; + + if (packet.IsEndOfStream) + headerType |= OggEndOfStreamFlag; + + using var header = new MemoryStream(OggPageHeaderBaseSize + segmentCount); + header.Write(Encoding.ASCII.GetBytes("OggS")); + header.Write(ByteParsers.Byte.EncodeValue(0, out _)); + header.Write(ByteParsers.Byte.EncodeValue((byte)headerType, out _)); + header.Write(ByteParsers.Int64.EncodeValue(packet.GranulePosition, out _)); + header.Write(ByteParsers.Int32.EncodeValue(serialNumber, out _)); + header.Write(ByteParsers.Int32.EncodeValue(sequenceNumber, out _)); + header.Write(ByteParsers.UInt32.EncodeValue(0u, out _)); + header.Write(ByteParsers.Byte.EncodeValue((byte)segmentCount, out _)); + + var remainingBytes = packetLength; + for (var segmentIndex = 0; segmentIndex < segmentCount; segmentIndex++) + { + byte segmentSize; + if (remainingBytes >= OggMaxSegmentSize) + segmentSize = OggMaxSegmentSize; + else + segmentSize = (byte)remainingBytes; + + header.Write(ByteParsers.Byte.EncodeValue(segmentSize, out _)); + remainingBytes -= OggMaxSegmentSize; + } + + var headerBytes = header.ToArray(); + var page = new byte[headerBytes.Length + packetLength]; + headerBytes.CopyTo(page, 0); + packet.PacketData.CopyTo(page, headerBytes.Length); + + var cyclicRedundancyCheck = ComputeOggCyclicRedundancyCheck32(page); + BinaryPrimitives.WriteUInt32LittleEndian(page.AsSpan(OggCrcFieldByteOffset), cyclicRedundancyCheck); + + output.Write(page); + } + + private static uint[] BuildOggCyclicRedundancyCheckTable() + { + var table = new uint[OggCrc32TableSize]; + for (uint tableIndex = 0; tableIndex < OggCrc32TableSize; tableIndex++) + { + var cyclicRedundancyCheck = tableIndex << OggCrc32TopByteShift; + for (var bitIndex = 0; bitIndex < BitHelper.BitsPerByte; bitIndex++) + { + if (BitHelper.IsBitSet(unchecked((int)cyclicRedundancyCheck), OggCrc32TopBitIndex)) + cyclicRedundancyCheck = (cyclicRedundancyCheck << 1) ^ OggCrc32Polynomial; + else + cyclicRedundancyCheck <<= 1; + } + + table[tableIndex] = cyclicRedundancyCheck; + } + return table; + } + + private static uint ComputeOggCyclicRedundancyCheck32(byte[] data) + { + uint cyclicRedundancyCheck = 0; + foreach (var pageByte in data) + { + var tableIndex = (int)(BitHelper.ExtractBits(cyclicRedundancyCheck, OggCrc32TopByteShift, BitHelper.BitsPerByte) ^ pageByte); + cyclicRedundancyCheck = (cyclicRedundancyCheck << BitHelper.BitsPerByte) ^ s_oggCyclicRedundancyCheckTable[tableIndex]; + } + return cyclicRedundancyCheck; + } + } +} diff --git a/Shared/GameFiles/Shared.GameFormats.csproj b/Shared/GameFiles/Shared.GameFormats.csproj index 8e1131c3b..e4a1f1e41 100644 --- a/Shared/GameFiles/Shared.GameFormats.csproj +++ b/Shared/GameFiles/Shared.GameFormats.csproj @@ -7,6 +7,11 @@ preview + + + + + diff --git a/Shared/GameFiles/Wwise/Hirc/V136/Shared/AdvSettingsParams_V136.cs b/Shared/GameFiles/Wwise/Hirc/V136/Shared/AdvSettingsParams_V136.cs index a1f6bff7a..0204c9708 100644 --- a/Shared/GameFiles/Wwise/Hirc/V136/Shared/AdvSettingsParams_V136.cs +++ b/Shared/GameFiles/Wwise/Hirc/V136/Shared/AdvSettingsParams_V136.cs @@ -4,11 +4,15 @@ namespace Shared.GameFormats.Wwise.Hirc.V136.Shared { public class AdvSettingsParams_V136 { + private const byte LoudnessNormalisationBit = 0x04; + public byte BitVector { get; set; } public byte VirtualQueueBehavior { get; set; } public ushort MaxNumInstance { get; set; } public byte BelowThresholdBehavior { get; set; } public byte BitVector2 { get; set; } + // Corresponds to "Enable Loudness Normalization" setting on Sounds in Wwise + public bool EnableLoudnessNormalisation { get; set; } public void ReadData(ByteChunk chunk) { @@ -17,10 +21,16 @@ public void ReadData(ByteChunk chunk) MaxNumInstance = chunk.ReadUShort(); BelowThresholdBehavior = chunk.ReadByte(); BitVector2 = chunk.ReadByte(); + EnableLoudnessNormalisation = (BitVector2 & LoudnessNormalisationBit) != 0; } public byte[] WriteData() { + if (EnableLoudnessNormalisation) + BitVector2 = (byte)(BitVector2 | LoudnessNormalisationBit); + else + BitVector2 = (byte)(BitVector2 & ~LoudnessNormalisationBit); + using var memStream = new MemoryStream(); memStream.Write(ByteParsers.Byte.EncodeValue(BitVector, out _)); memStream.Write(ByteParsers.Byte.EncodeValue(VirtualQueueBehavior, out _)); diff --git a/Shared/GameFiles/Wwise/Hirc/V136/Shared/AkPluginParam_V136.cs b/Shared/GameFiles/Wwise/Hirc/V136/Shared/AkPluginParam_V136.cs index e5a56d597..f2bf6b177 100644 --- a/Shared/GameFiles/Wwise/Hirc/V136/Shared/AkPluginParam_V136.cs +++ b/Shared/GameFiles/Wwise/Hirc/V136/Shared/AkPluginParam_V136.cs @@ -43,16 +43,16 @@ public static AkPluginParam_V136 ReadData(ByteChunk chunk, uint plugin_id, uint public class CAkFxSrcSilenceParams_V136 : AkPluginParam_V136 { public float Duration { get; set; } - public float RandomizedLengthMinus { get; set; } - public float RandomizedLengthPlus { get; set; } + public float RandomisedLengthMinus { get; set; } + public float RandomisedLengthPlus { get; set; } public static CAkFxSrcSilenceParams_V136 ReadData(ByteChunk chunk) { return new CAkFxSrcSilenceParams_V136 { Duration = chunk.ReadSingle(), - RandomizedLengthMinus = chunk.ReadSingle(), - RandomizedLengthPlus = chunk.ReadSingle() + RandomisedLengthMinus = chunk.ReadSingle(), + RandomisedLengthPlus = chunk.ReadSingle() }; } } diff --git a/Shared/GameFiles/Wwise/Wem/V132/AkdChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/AkdChunk.cs new file mode 100644 index 000000000..168bdcd1a --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/AkdChunk.cs @@ -0,0 +1,157 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + // Wwise "akd " RIFF chunk — pre-computed analysis data baked into a WEM during the conversion/analysis phase. + // + // Purpose: + // The chunk stores per-file loudness and HDR envelope data so the Wwise runtime can apply normalisation + // and dynamic gain without re-analysing the audio on load. It feeds two independent Wwise features: + // + // 1. Source Loudness Normalisation (Sound Properties -> "Enable Loudness Normalisation" checkbox). + // When enabled, the runtime multiplies playback gain by LoudnessNormalisationGain so the sound + // hits a target loudness level (default -23 LUFS per ITU-R BS.1770). Wwise documentation states + // the formula as: gain_dB = target_dB − measured_dB. The value stored here is the equivalent + // linear amplitude multiplier: gain = 10^(gain_dB / 20). + // The normalisation gain is intentionally transparent to Wwise's voice pipeline — it does not + // affect virtual-voice threshold evaluation, HDR attenuation, or the Voice Monitor. + // + // 2. HDR Bus processing (High Dynamic Range). + // HdrPeakRms and the optional per-block envelope data let the HDR bus track the instantaneous + // loudness of a playing sound over time and feed it back into the HDR gain computer, enabling + // the bus to attenuate competing quiet sounds when a loud sound plays. + // + // Generation: + // Wwise generates this chunk during WAV→WEM conversion when AnalysisTypes ≥ 1 in the .wsources XML: + // AnalysisTypes="1" - HDR analysis only (LoudnessNormalisationGain = 1.0, no loudness correction) + // AnalysisTypes="2" - Loudness analysis (BS.1770 K-weighted integrated loudness with gating) + // AnalysisTypes="3" - Both HDR and loudness analysis + // AnalysisTypes="0" - No akd chunk emitted at all + // The IncludeEnvelope attribute in .wsources controls whether the variable envelope tail is written. + // + // Reverse Engineering: + // Layout and field names were proven by disassembling WwiseSoundEngine.dll (Wwise 2019.2.15.7667) + // using the Python 'capstone' disassembly library against the PE export table via 'pefile': + // + // • WriteAnalysisChunk (IPluginHostConversionHelpers) — the function that serialises this chunk. + // Disassembly showed four sequential movss/mov writes: float, float, dword, float — confirming + // the layout and that field 3 is uint32 (not float as the zero-filled corpus initially suggested). + // + // • WavLoudnessAnalysis / CalculateLoudnessNormalisationGain — string literals found by scanning + // the .rdata section of WwiseSoundEngine.dll, confirming the name of field 1. + // + // • GetDownmixNormalisationGain — function name found in the same string scan, confirming field 2. + // + // • WavHdrAnalysis::GetPeakRMS() — string found in WwiseSoundEngine.dll; disassembly of the + // function body showed 'movss xmm0, [rcx+0x50]; ret', confirming it returns a single float + // (HdrPeakRms, field 4) read from a fixed struct offset. + // + // • AkFileParser::EnvelopePoint / SourceContext::SetEnvelopePoint — string and disassembly proving + // the 6-byte envelope record layout: uint32 sample-index + uint16 quantized-dB amplitude. + // + // Loudness value format validated empirically: our BS.1770 encoder produces LoudnessNormalisationGain + // = 0.358357 for harmony_of_shadows.wav; Wwise produces 0.358360 — a difference of 0.000002, + // confirming the value is a linear amplitude multiplier (not a dB scalar). + public class AkdChunk : RiffChunk + { + public const string ChunkTag = "akd "; + + private const uint MinimumPayloadSize = 16; + public const uint EnvelopePointSize = 6; + + // Linear amplitude multiplier applied at runtime when "Enable Loudness Normalisation" is active on the Sound. + // Computed as: gain = 10^((targetLoudness − measuredLoudness) / 20), where loudness is measured + // using ITU-R BS.1770 K-weighted integrated loudness with absolute (−70 LUFS) and relative (−10 LU) gating. + // Default Wwise target is −23 LUFS (ITU-R BS.1770 / EBU R128). + // Name confirmed from WavLoudnessAnalysis / CalculateLoudnessNormalisationGain strings in WwiseSoundEngine.dll. + public float LoudnessNormalisationGain { get; set; } + + // Linear amplitude multiplier that corrects for level change when downmixing multichannel audio to fewer channels. + // For mono and stereo sources this is always 1.0. For N-channel surround: sqrt(2 / N). + // Name confirmed from GetDownmixNormalisationGain string in WwiseSoundEngine.dll. + public float DownmixNormalisationGain { get; set; } = 1.0f; + + // Number of HDR envelope point records in the variable-length tail (6 bytes each). + // Zero when the file was converted without IncludeEnvelope or without HDR analysis. + public uint HdrEnvelopePointCount { get; set; } + + // Peak RMS value of the HDR loudness envelope across the whole file, used by the Wwise HDR bus. + // Name and type confirmed by disassembling WavHdrAnalysis::GetPeakRMS() in WwiseSoundEngine.dll: + // the function body is `movss xmm0, [rcx+0x50]; ret`, returning a single float from a fixed struct offset. + // Zero when no HDR analysis was performed (AnalysisTypes < 1 or IncludeEnvelope not set). + public float HdrPeakRms { get; set; } + + // Raw bytes of packed AkFileParser::EnvelopePoint records that follow the fixed 16-byte header. + // Each record is 6 bytes: uint32 sample-index + uint16 quantized-dB amplitude. + // Layout proven from SourceContext::SetEnvelopePoint / SetEnvelopePoints disassembly in WwiseSoundEngine.dll. + // Empty (length 0) when HdrEnvelopePointCount is zero. + public byte[] EnvelopeData { get; set; } = []; + + public AkdChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < MinimumPayloadSize) + throw new InvalidDataException($"WEM akd chunk must be at least {MinimumPayloadSize} bytes, got {chunk.BytesLeft}."); + + LoudnessNormalisationGain = chunk.ReadSingle(); + DownmixNormalisationGain = chunk.ReadSingle(); + HdrEnvelopePointCount = chunk.ReadUInt32(); + HdrPeakRms = chunk.ReadSingle(); + + if (chunk.BytesLeft > 0) + EnvelopeData = chunk.ReadBytes(chunk.BytesLeft); + + var expectedEnvelopeByteCount = checked((int)(HdrEnvelopePointCount * EnvelopePointSize)); + if (EnvelopeData.Length != expectedEnvelopeByteCount) + throw new InvalidDataException($"WEM akd chunk envelope byte count mismatch. Header declares {HdrEnvelopePointCount} points ({expectedEnvelopeByteCount} bytes), but chunk contains {EnvelopeData.Length} bytes."); + } + + public override byte[] WriteData() + { + if (EnvelopeData.Length % EnvelopePointSize != 0) + throw new InvalidDataException($"WEM akd chunk envelope data length must be a multiple of {EnvelopePointSize} bytes."); + + var derivedEnvelopePointCount = (uint)(EnvelopeData.Length / EnvelopePointSize); + var envelopePointCount = HdrEnvelopePointCount; + + if (envelopePointCount == 0 && derivedEnvelopePointCount > 0) + envelopePointCount = derivedEnvelopePointCount; + else if (envelopePointCount != derivedEnvelopePointCount) + throw new InvalidDataException($"WEM akd chunk envelope point count mismatch. Header declares {HdrEnvelopePointCount} points but payload encodes {derivedEnvelopePointCount}."); + + var totalSize = MinimumPayloadSize + (uint)EnvelopeData.Length; + using var stream = new MemoryStream((int)totalSize); + + stream.Write(BitConverter.GetBytes(LoudnessNormalisationGain)); + stream.Write(BitConverter.GetBytes(DownmixNormalisationGain)); + stream.Write(BitConverter.GetBytes(envelopePointCount)); + stream.Write(BitConverter.GetBytes(HdrPeakRms)); + + if (EnvelopeData.Length > 0) + stream.Write(EnvelopeData); + + var byteArray = stream.ToArray(); + + var sanityReload = new AkdChunk(); + sanityReload.ReadChunk(new ByteChunk(byteArray)); + + return byteArray; + } + + public int GetSize() + { + if (EnvelopeData.Length % EnvelopePointSize != 0) + throw new InvalidDataException($"Envelope data length must be a multiple of {EnvelopePointSize} bytes."); + + var derivedEnvelopePointCount = (uint)(EnvelopeData.Length / EnvelopePointSize); + if (HdrEnvelopePointCount != 0 && HdrEnvelopePointCount != derivedEnvelopePointCount) + throw new InvalidDataException($"Envelope point count mismatch. Expected {HdrEnvelopePointCount} points but got {derivedEnvelopePointCount}."); + + return (int)(MinimumPayloadSize + (uint)EnvelopeData.Length); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/CueChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/CueChunk.cs new file mode 100644 index 000000000..0cce49782 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/CueChunk.cs @@ -0,0 +1,39 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + // The cue chunk contains cue point entries for timing markers. + public class CueChunk : RiffChunk + { + public const string ChunkTag = "cue "; + + public List CuePoints { get; set; } = []; + + public CueChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + var count = checked((int)chunk.ReadUInt32()); + for (var i = 0; i < count; i++) + CuePoints.Add(CuePoint.ReadData(chunk)); + } + + public override byte[] WriteData() + { + using var stream = new MemoryStream(); + + stream.Write(ByteParsers.UInt32.EncodeValue((uint)CuePoints.Count, out _)); + foreach (var point in CuePoints) + stream.Write(point.WriteData()); + + var byteArray = stream.ToArray(); + var sanityReload = new CueChunk(); + sanityReload.ReadChunk(new ByteChunk(byteArray)); + + return byteArray; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/CuePoint.cs b/Shared/GameFiles/Wwise/Wem/V132/CuePoint.cs new file mode 100644 index 000000000..b401fea43 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/CuePoint.cs @@ -0,0 +1,36 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public class CuePoint + { + public uint Id { get; set; } + public uint Position { get; set; } + public uint DataChunkId { get; set; } + public uint ChunkStart { get; set; } + public uint BlockStart { get; set; } + public uint SampleOffset { get; set; } + + public static CuePoint ReadData(ByteChunk chunk) => new() + { + Id = chunk.ReadUInt32(), + Position = chunk.ReadUInt32(), + DataChunkId = chunk.ReadUInt32(), + ChunkStart = chunk.ReadUInt32(), + BlockStart = chunk.ReadUInt32(), + SampleOffset = chunk.ReadUInt32(), + }; + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.UInt32.EncodeValue(Id, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(Position, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(DataChunkId, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(ChunkStart, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(BlockStart, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SampleOffset, out _)); + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/DataChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/DataChunk.cs new file mode 100644 index 000000000..b245365e0 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/DataChunk.cs @@ -0,0 +1,66 @@ +using Shared.ByteParsing; +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + // The data chunk contains seek / setup data and encoded audio packets. + public class DataChunk : RiffChunk + { + public const string ChunkTag = "data"; + public const int SetupPacketLengthPrefixSize = 2; + + public List SeekTable { get; set; } = []; + public byte[] SetupPacket { get; set; } = []; + public List AudioPackets { get; set; } = []; + public FmtChunk? FmtChunk { get; set; } + + public DataChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + if (FmtChunk == null) + throw new InvalidOperationException("Set FmtChunk before calling ReadData."); + + var recordCount = FmtChunk.SeekTableSize / WemSeekTableRecord.Size; + for (var i = 0; i < recordCount; i++) + SeekTable.Add(WemSeekTableRecord.ReadData(chunk)); + + var setupPacketSize = chunk.ReadUShort(); + SetupPacket = chunk.ReadBytes(setupPacketSize); + + var bytesConsumed = (int)FmtChunk.SeekTableSize + SetupPacketLengthPrefixSize + setupPacketSize; + var audioStart = (int)FmtChunk.FirstAudioPacketOffset; + if (audioStart > bytesConsumed) + chunk.Advance(audioStart - bytesConsumed); + + while (chunk.BytesLeft >= WemAudioPacket.LengthPrefixSize) + AudioPackets.Add(WemAudioPacket.ReadData(chunk)); + } + + public override byte[] WriteData() + { + using var stream = new MemoryStream(); + + foreach (var record in SeekTable) + stream.Write(record.WriteData()); + + stream.Write(ByteParsers.UShort.EncodeValue((ushort)SetupPacket.Length, out _)); + stream.Write(SetupPacket); + + foreach (var packet in AudioPackets) + stream.Write(packet.WriteData()); + + var byteArray = stream.ToArray(); + if (FmtChunk != null) + { + var sanityReload = new DataChunk { FmtChunk = FmtChunk }; + sanityReload.ReadChunk(new ByteChunk(byteArray)); + } + + return byteArray; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs new file mode 100644 index 000000000..36e0f77b1 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs @@ -0,0 +1,34 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132.Decoding +{ + public class VorbisCommentHeader + { + public byte PacketType { get; } = 0x03; + public string HeaderTag { get; } = "vorbis"; + public uint FramingBit { get; } = 1u; + public string VendorString { get; set; } = "WwiseVorb"; + public List UserComments { get; set; } = []; + + public byte[] WriteData() + { + var writer = new BitWriter(128); + writer.WriteByte(PacketType); + writer.WriteAscii(HeaderTag); + writer.WriteBits((uint)VendorString.Length, 32); + writer.WriteAscii(VendorString); + writer.WriteBits((uint)UserComments.Count, 32); + + foreach (var comment in UserComments) + { + writer.WriteBits((uint)comment.Length, 32); + writer.WriteAscii(comment); + } + + writer.WriteBits(FramingBit, 1); + writer.AlignToByte(); + + return writer.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisDecodeResult.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisDecodeResult.cs new file mode 100644 index 000000000..b87279127 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisDecodeResult.cs @@ -0,0 +1,15 @@ +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Shared.GameFormats.Wwise.Wem.V132.Decoding +{ + public class VorbisDecodeResult + { + public byte[] CommentPacket { get; set; } = []; + public byte[] IdentificationPacket { get; set; } = []; + public int LargeBlockSize { get; set; } + public int SmallBlockSize { get; set; } + public bool UsesWwisePacketHeaderVariant { get; set; } + public VorbisModeConfiguration ModeConfig { get; set; } = new(); + public byte[] SetupPacket { get; set; } = []; + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs new file mode 100644 index 000000000..442bc48a0 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs @@ -0,0 +1,37 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132.Decoding +{ + public class VorbisIdentificationHeader(WemFile wemFile) + { + public byte PacketType { get; } = 0x01; + public string HeaderTag { get; } = "vorbis"; + public uint Version { get; } = 0u; + public uint BitrateMaximum { get; } = 0u; + public uint BitrateMinimum { get; } = 0u; + public uint FramingBit { get; } = 1u; + public byte Channels { get; set; } = (byte)wemFile.FmtChunk.Channels; + public uint SampleRate { get; set; } = wemFile.FmtChunk.SampleRate; + public uint NominalBitrate { get; set; } = wemFile.FmtChunk.AverageBytesPerSecond * BitHelper.BitsPerByte; + public byte SmallBlockSizeExponent { get; set; } = wemFile.FmtChunk.SmallBlockSizeExponent; + public byte LargeBlockSizeExponent { get; set; } = wemFile.FmtChunk.LargeBlockSizeExponent; + + public byte[] WriteData() + { + var writer = new BitWriter(64); + writer.WriteByte(PacketType); + writer.WriteAscii(HeaderTag); + writer.WriteBits(Version, 32); + writer.WriteByte(Channels); + writer.WriteBits(SampleRate, 32); + writer.WriteBits(BitrateMaximum, 32); + writer.WriteBits(NominalBitrate, 32); + writer.WriteBits(BitrateMinimum, 32); + writer.WriteBits(SmallBlockSizeExponent, 4); + writer.WriteBits(LargeBlockSizeExponent, 4); + writer.WriteBits(FramingBit, 1); + writer.AlignToByte(); + return writer.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs new file mode 100644 index 000000000..b95ab77cd --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs @@ -0,0 +1,275 @@ +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Codecs; +using Shared.GameFormats.Wwise.Wem.V132; +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Shared.GameFormats.Wwise.Wem.V132.Decoding +{ + public class WemVorbisDecoder(WwiseCodebookLibrary codebookLibrary) + { + private readonly WwiseCodebookLibrary _codebookLibrary = codebookLibrary; + + private const int XiphCodecPrivatePrefixLength = 16; + private const int XiphLacingContinuationValue = 255; + private const int VorbisModeTypeBitWidth = 16; + + public VorbisAudio Decode(WemFile wemFile) + { + var decodeResult = BuildDecodeResult(wemFile); + return BuildVorbis(wemFile, decodeResult); + } + + private VorbisDecodeResult BuildDecodeResult(WemFile wemFile) + { + var decodeResult = new VorbisDecodeResult + { + CommentPacket = new VorbisCommentHeader().WriteData(), + IdentificationPacket = new VorbisIdentificationHeader(wemFile).WriteData(), + LargeBlockSize = 1 << wemFile.FmtChunk.LargeBlockSizeExponent, + SmallBlockSize = 1 << wemFile.FmtChunk.SmallBlockSizeExponent, + UsesWwisePacketHeaderVariant = wemFile.FmtChunk.SmallBlockSizeExponent != wemFile.FmtChunk.LargeBlockSizeExponent, + }; + ExpandSetupPacket(wemFile.DataChunk.SetupPacket, wemFile.FmtChunk.Channels, decodeResult); + return decodeResult; + } + + private static VorbisAudio BuildVorbis(WemFile wemFile, VorbisDecodeResult decodeResult) + { + var wemPackets = wemFile.DataChunk.AudioPackets; + var rebuiltPackets = new List(wemPackets.Count); + var perPacketBlockSizes = new List(wemPackets.Count); + var previousPacketIsLargeBlock = false; + + for (var i = 0; i < wemPackets.Count; i++) + { + var payload = wemPackets[i].Data; + if (payload.Length == 0) + throw new InvalidDataException("Wwise Vorbis packet size cannot be zero."); + + byte? nextPacketFirstByte = null; + if (i + 1 < wemPackets.Count && wemPackets[i + 1].Data.Length > 0) + nextPacketFirstByte = wemPackets[i + 1].Data[0]; + + var currentPacketIsLargeBlock = PacketUsesLargeBlock(payload[0], decodeResult); + var rebuiltPacket = RebuildAudioPacket(payload, nextPacketFirstByte, decodeResult, previousPacketIsLargeBlock, currentPacketIsLargeBlock); + + rebuiltPackets.Add(rebuiltPacket); + perPacketBlockSizes.Add(currentPacketIsLargeBlock); + previousPacketIsLargeBlock = currentPacketIsLargeBlock; + } + + var packetTimestamps = ComputePacketTimestampsMilliseconds(perPacketBlockSizes, decodeResult, wemFile.FmtChunk.SampleCount, wemFile.FmtChunk.SampleRate); + var vorbisPackets = new List(); + for (var i = 0; i < rebuiltPackets.Count; i++) + vorbisPackets.Add(new VorbisAudioPacket { Data = rebuiltPackets[i], TimestampMilliseconds = packetTimestamps[i] }); + + return new VorbisAudio + { + Channels = checked((byte)wemFile.FmtChunk.Channels), + VorbisCodecPrivateData = BuildXiphLacedCodecPrivate(decodeResult.IdentificationPacket, decodeResult.CommentPacket, decodeResult.SetupPacket), + Packets = vorbisPackets, + SampleCount = wemFile.FmtChunk.SampleCount, + SampleRate = wemFile.FmtChunk.SampleRate, + }; + } + + private void ExpandSetupPacket(byte[] compactSetupBytes, int channels, VorbisDecodeResult decodeResult) + { + var largeBufferCapacity = 4096; + uint setupPacketType = 0x05; + var vorbisHeaderTag = "vorbis"; + uint vorbisFramingBit = 1; + + var input = new BitChunk(compactSetupBytes); + var output = new BitWriter(largeBufferCapacity); + + output.WriteByte((byte)setupPacketType); + output.WriteAscii(vorbisHeaderTag); + + var codebookCount = TranscribeCodebooks(input, output); + + WriteTimeDomainTransformPlaceholders(output); + + var floorCount = WwiseVorbisBitstreamTranscriber.TranscribeFloorConfigurationsWwiseToVorbis(input, output, codebookCount); + var residueCount = WwiseVorbisBitstreamTranscriber.TranscribeResidueConfigurationsWwiseToVorbis(input, output, codebookCount); + var mappingCount = WwiseVorbisBitstreamTranscriber.TranscribeMappingConfigurationsWwiseToVorbis(input, output, channels, floorCount, residueCount); + + decodeResult.ModeConfig = TranscribeModeConfigurations(input, output, mappingCount); + + output.WriteBits(vorbisFramingBit, 1); + + decodeResult.SetupPacket = output.ToArray(); + } + + private static byte[] RebuildAudioPacket(byte[] payload, byte? nextFirstByte, VorbisDecodeResult decodeResult, bool previousPacketIsLargeBlock, bool currentPacketIsLargeBlock) + { + uint audioPacketTypeValue = 0; + if (!decodeResult.UsesWwisePacketHeaderVariant) + return payload; + + var input = new BitChunk(payload); + var output = new BitWriter(Math.Max(payload.Length + 2, XiphCodecPrivatePrefixLength)); + + output.WriteBits(audioPacketTypeValue, 1); + + var modeNumber = decodeResult.ModeConfig.ModeBits > 0 ? (int)input.ReadBits(decodeResult.ModeConfig.ModeBits) : 0; + output.WriteBits((uint)modeNumber, decodeResult.ModeConfig.ModeBits); + + var remainderBitCount = BitHelper.BitsPerByte - decodeResult.ModeConfig.ModeBits; + uint remainderBits = 0; + if (remainderBitCount > 0) + remainderBits = input.ReadBits(remainderBitCount); + + if (currentPacketIsLargeBlock) + { + uint previousBlockBits = 0; + if (previousPacketIsLargeBlock) + previousBlockBits = 1; + output.WriteBits(previousBlockBits, 1); + + uint nextBlockBits = 0; + var nextPacketIsLargeBlock = nextFirstByte.HasValue && PacketUsesLargeBlock(nextFirstByte.Value, decodeResult); + if (nextPacketIsLargeBlock) + nextBlockBits = 1; + output.WriteBits(nextBlockBits, 1); + } + + if (remainderBitCount > 0) + output.WriteBits(remainderBits, remainderBitCount); + + for (var byteIndex = 1; byteIndex < payload.Length; byteIndex++) + output.WriteByte(payload[byteIndex]); + + output.AlignToByte(); + return output.ToArray(); + } + + private static bool PacketUsesLargeBlock(byte firstByte, VorbisDecodeResult decodeResult) + { + if (decodeResult.ModeConfig.ModeBits == 0) + return decodeResult.ModeConfig.ModeBlockSizes[0]; + else + { + var modeMask = (1 << decodeResult.ModeConfig.ModeBits) - 1; + var modeNumber = firstByte & modeMask; + return decodeResult.ModeConfig.ModeBlockSizes[modeNumber]; + } + } + + private static long[] ComputePacketTimestampsMilliseconds(List blockSizesPerPacket, VorbisDecodeResult decodeResult, int sampleCount, uint sampleRate) + { + var granuleOverlapDivisor = 4; + var timestampMillisecondsScale = 1000; + var timestamps = new long[blockSizesPerPacket.Count]; + if (blockSizesPerPacket.Count == 0) + return timestamps; + + long accumulatedSamples = 0; + int? previousBlockSize = null; + for (var index = 0; index < blockSizesPerPacket.Count; index++) + { + timestamps[index] = accumulatedSamples; + var currentBlockSize = blockSizesPerPacket[index] ? decodeResult.LargeBlockSize : decodeResult.SmallBlockSize; + if (previousBlockSize.HasValue) + accumulatedSamples += (previousBlockSize.Value + currentBlockSize) / granuleOverlapDivisor; + previousBlockSize = currentBlockSize; + } + + var scale = 1.0; + if (accumulatedSamples > 0 && sampleCount > 0) + scale = (double)sampleCount / accumulatedSamples; + + for (var index = 0; index < timestamps.Length; index++) + timestamps[index] = (long)Math.Round(timestamps[index] * scale * timestampMillisecondsScale / sampleRate); + + return timestamps; + } + + private static byte[] BuildXiphLacedCodecPrivate(byte[] identificationPacket, byte[] commentPacket, byte[] setupPacket) + { + byte xiphLacedHeaderPacketCountMinusOne = 0x02; + var bytes = new List(identificationPacket.Length + commentPacket.Length + setupPacket.Length + XiphCodecPrivatePrefixLength) { xiphLacedHeaderPacketCountMinusOne }; + WriteXiphLacedSize(bytes, identificationPacket.Length); + WriteXiphLacedSize(bytes, commentPacket.Length); + bytes.AddRange(identificationPacket); + bytes.AddRange(commentPacket); + bytes.AddRange(setupPacket); + return bytes.ToArray(); + } + + private static void WriteXiphLacedSize(List output, int size) + { + while (size >= XiphLacingContinuationValue) + { + output.Add(XiphLacingContinuationValue); + size -= XiphLacingContinuationValue; + } + output.Add((byte)size); + } + + private int TranscribeCodebooks(BitChunk input, BitWriter output) + { + var codebookCountField = input.ReadBits(8); + output.WriteBits(codebookCountField, 8); + + var codebookCount = (int)codebookCountField + 1; + for (var codebookIndex = 0; codebookIndex < codebookCount; codebookIndex++) + { + var codebookId = (int)input.ReadBits(10); + var codebook = _codebookLibrary.GetCodebook(codebookId); + + var codebookInput = new BitChunk(codebook.Data); + for (var bitIndex = 0; bitIndex < codebook.BitCount; bitIndex += 32) + { + var bitsToWrite = Math.Min(32, codebook.BitCount - bitIndex); + var value = codebookInput.ReadBits(bitsToWrite); + output.WriteBits(value, bitsToWrite); + } + } + + return codebookCount; + } + + private static void WriteTimeDomainTransformPlaceholders(BitWriter output) + { + uint vorbisTimeDomainTransformCountField = 0; + var vorbisTimeDomainTransformCountBitWidth = 6; + uint vorbisTimeDomainTransformType = 0; + + output.WriteBits(vorbisTimeDomainTransformCountField, vorbisTimeDomainTransformCountBitWidth); + output.WriteBits(vorbisTimeDomainTransformType, WwiseVorbisBitstreamTranscriber.VorbisMappingTypeBitWidth); + } + + private static VorbisModeConfiguration TranscribeModeConfigurations(BitChunk input, BitWriter output, int mappingCount) + { + uint vorbisModeWindowType = 0; + uint vorbisModeTransformType = 0; + var modeCountField = input.ReadBits(6); + output.WriteBits(modeCountField, 6); + + var modeCount = (int)modeCountField + 1; + var modeBlockSizes = new bool[modeCount]; + for (var modeIndex = 0; modeIndex < modeCount; modeIndex++) + { + var blockSizeBit = input.ReadBits(1); + output.WriteBits(blockSizeBit, 1); + modeBlockSizes[modeIndex] = blockSizeBit != 0; + + output.WriteBits(vorbisModeWindowType, VorbisModeTypeBitWidth); + output.WriteBits(vorbisModeTransformType, VorbisModeTypeBitWidth); + + var mappingIndex = input.ReadBits(8); + if (mappingIndex >= mappingCount) + throw new InvalidDataException("Wwise Vorbis mode mapping index is out of range."); + + output.WriteBits(mappingIndex, 8); + } + + return new VorbisModeConfiguration + { + ModeBits = WwiseVorbisBitstreamTranscriber.IntegerLog(modeCount - 1), + ModeBlockSizes = modeBlockSizes, + }; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/BiquadCoefficients.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/BiquadCoefficients.cs new file mode 100644 index 000000000..6a5e9aefe --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/BiquadCoefficients.cs @@ -0,0 +1,4 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public record BiquadCoefficients(double B0, double B1, double B2, double A1, double A2); +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/KWeightingCoefficients.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/KWeightingCoefficients.cs new file mode 100644 index 000000000..aba83018d --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/KWeightingCoefficients.cs @@ -0,0 +1,4 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public record KWeightingCoefficients(BiquadCoefficients PreFilter, BiquadCoefficients RlbFilter); +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisCodebook.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisCodebook.cs new file mode 100644 index 000000000..2830c7aa7 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisCodebook.cs @@ -0,0 +1,15 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class VorbisCodebook(byte[] data, int bitCount) + { + public const int SyncPatternBitWidth = 24; + public const uint SyncPattern = 0x564342u; + public const int DimensionsBitWidth = 16; + public const int EntriesBitWidth = 24; + public const int VqFloatFieldBitWidth = 32; + public const int ValueLengthBitWidth = 4; + + public byte[] Data { get; } = data; + public int BitCount { get; } = bitCount; + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisModeConfiguration.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisModeConfiguration.cs new file mode 100644 index 000000000..e953d515b --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/VorbisModeConfiguration.cs @@ -0,0 +1,8 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class VorbisModeConfiguration + { + public int ModeBits { get; set; } + public bool[] ModeBlockSizes { get; set; } = []; + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemAudioPacket.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemAudioPacket.cs new file mode 100644 index 000000000..a9337b66e --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemAudioPacket.cs @@ -0,0 +1,28 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WemAudioPacket + { + public const int LengthPrefixSize = sizeof(ushort); + + public byte[] Data { get; set; } = []; + public long GranulePosition { get; set; } + + public static WemAudioPacket ReadData(ByteChunk chunk) + { + var packet = new WemAudioPacket(); + var dataSize = chunk.ReadUShort(); + packet.Data = chunk.ReadBytes(dataSize); + return packet; + } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.UShort.EncodeValue((ushort)Data.Length, out _)); + stream.Write(Data); + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemEncodingSettings.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemEncodingSettings.cs new file mode 100644 index 000000000..06a2fec04 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemEncodingSettings.cs @@ -0,0 +1,14 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WemEncodingSettings + { + public float Quality { get; set; } = 0.4f; + public bool UseSeekTable { get; set; } = true; + + // Akd Chunk + public bool GenerateAkdChunk { get; set; } = true; + public float TargetLoudnessDb { get; set; } = -23.0f; + public bool IncludeHdrEnvelopeData { get; set; } = false; + public int HdrEnvelopeWindowSizeSamples { get; set; } = 1024; + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemSeekTableRecord.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemSeekTableRecord.cs new file mode 100644 index 000000000..a745812b5 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemSeekTableRecord.cs @@ -0,0 +1,29 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WemSeekTableRecord + { + public const uint Size = 4; + + public ushort GranuleDelta { get; set; } + public ushort ByteCount { get; set; } + + public static WemSeekTableRecord ReadData(ByteChunk chunk) + { + return new WemSeekTableRecord + { + GranuleDelta = chunk.ReadUShort(), + ByteCount = chunk.ReadUShort() + }; + } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.UShort.EncodeValue(GranuleDelta, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(ByteCount, out _)); + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs new file mode 100644 index 000000000..a1b965838 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs @@ -0,0 +1,712 @@ +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Codecs; +using Shared.GameFormats.Audio.Ogg; +using Shared.GameFormats.Audio.Wav; + +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WemVorbisEncoder(WwiseCodebookLibrary codebookLibrary) + { + private readonly WwiseCodebookLibrary _codebookLibrary = codebookLibrary; + + public WemEncodingSettings EncodingSettings { get; set; } = new WemEncodingSettings(); + + private const int EncodingWriteBufferSize = 512; + private const float DefaultSilenceDbFloor = -96.0f; + + public WemFile EncodeFromWav(byte[] wavData, WemEncodingSettings? encodingSettings = null) + { + ArgumentNullException.ThrowIfNull(wavData); + var wavFile = WavFile.CreateFromBytes(wavData); + var perChannelSamples = ConvertPcmToPerChannelFloat(wavFile.Audio, wavFile.FmtChunk.FormatTag); + return EncodeFloatSamplesToWem(perChannelSamples, checked(wavFile.Audio.Channels), checked((int)wavFile.Audio.SampleRate)); + } + + private WemFile EncodeFloatSamplesToWem(float[][] perChannelSamples, int channels, int sampleRate) + { + var vorbisIdPacketBlockSizeByteOffset = 28; + uint wemChannelMaskMetadataBits = 0x100; + var wemChannelMaskShift = 12; + uint waveMonoChannelMask = 4; + uint waveStereoChannelMask = 3; + uint waveSurroundChannelMask = 63; + uint waveFullSurroundChannelMask = 255; + + var info = OggVorbisEncoder.VorbisInfo.InitVariableBitRate(channels, sampleRate, EncodingSettings.Quality); + var comments = new OggVorbisEncoder.Comments(); + var identificationPacket = OggVorbisEncoder.HeaderPacketBuilder.BuildInfoPacket(info); + var booksPacket = OggVorbisEncoder.HeaderPacketBuilder.BuildBooksPacket(info); + OggVorbisEncoder.HeaderPacketBuilder.BuildCommentsPacket(comments); + + var processingState = OggVorbisEncoder.ProcessingState.Create(info); + var sampleCount = perChannelSamples[0].Length; + for (var readOffset = 0; readOffset < sampleCount; readOffset += EncodingWriteBufferSize) + { + var length = Math.Min(EncodingWriteBufferSize, sampleCount - readOffset); + processingState.WriteData(perChannelSamples, length, readOffset); + } + processingState.WriteEndOfStream(); + + var oggAudioPackets = new List(); + while (processingState.PacketOut(out var oggPacket)) + oggAudioPackets.Add(new OggAudioPacket { PacketData = oggPacket.PacketData, GranulePosition = oggPacket.GranulePosition }); + + var wemSetupData = CompressSetup(booksPacket.PacketData, channels); + var previousPacketIsLargeBlock = false; + var wemAudioPackets = new List(); + for (var i = 0; i < oggAudioPackets.Count; i++) + { + var wemPacketData = CompressAudioPacket(oggAudioPackets[i].PacketData, wemSetupData.ModeConfiguration, ref previousPacketIsLargeBlock); + wemAudioPackets.Add(new WemAudioPacket { Data = wemPacketData, GranulePosition = oggAudioPackets[i].GranulePosition }); + } + + var blockSizeByte = identificationPacket.PacketData[vorbisIdPacketBlockSizeByteOffset]; + var blockSize0Exponent = BitHelper.ExtractBits(blockSizeByte, 4, 4); + var blockSize1Exponent = BitHelper.ExtractBits(blockSizeByte, 0, 4); + var largeBlockSize = 1 << blockSize0Exponent; + var smallBlockSize = 1 << blockSize1Exponent; + + long lastGranulePosition; + if (wemAudioPackets.Count > 0) + lastGranulePosition = wemAudioPackets[wemAudioPackets.Count - 1].GranulePosition; + else + lastGranulePosition = sampleCount; + + var lastGranuleExtra = (int)(lastGranulePosition - sampleCount); + if (lastGranuleExtra < 0) + lastGranuleExtra = 0; + + var seekTable = new List(); + if (EncodingSettings.UseSeekTable) + seekTable = BuildSeekTable(wemAudioPackets, largeBlockSize, smallBlockSize, sampleCount, lastGranuleExtra, wemSetupData); + + var codebookHash = WwiseHash.Compute(wemSetupData.SetupPacketBytes); + var seekTableSize = (uint)(seekTable.Count * WemSeekTableRecord.Size); + var setupPacketSize = sizeof(ushort) + wemSetupData.SetupPacketBytes.Length; + var audioDataSize = wemAudioPackets.Sum(p => WemAudioPacket.LengthPrefixSize + p.Data.Length); + var totalDataSize = seekTableSize + setupPacketSize + audioDataSize; + var averageBytesPerSecond = (uint)(sampleCount > 0 ? totalDataSize * sampleRate / sampleCount : 0); + + uint channelMask; + if (channels == 1) + channelMask = waveMonoChannelMask; + else if (channels == 2) + channelMask = waveStereoChannelMask; + else if (channels == 6) + channelMask = waveSurroundChannelMask; + else if (channels == 8) + channelMask = waveFullSurroundChannelMask; + else + channelMask = (uint)((1 << channels) - 1u); + + var fmtChunkChannelMask = (uint)channels | wemChannelMaskMetadataBits | (channelMask << wemChannelMaskShift); + + var fmtChunk = new FmtChunk + { + Channels = checked((ushort)channels), + SampleRate = checked((uint)sampleRate), + AverageBytesPerSecond = averageBytesPerSecond, + ChannelMask = fmtChunkChannelMask, + SampleCount = sampleCount, + SetupPacketSize = (uint)setupPacketSize, + AudioDataOffset = (uint)(setupPacketSize + audioDataSize), + LastGranuleExtra = (ushort)lastGranuleExtra, + SeekTableSize = seekTableSize, + FirstAudioPacketOffset = seekTableSize + (uint)setupPacketSize, + MaxPacketSize = (ushort)(wemAudioPackets.Count > 0 ? wemAudioPackets.Max(packet => packet.Data.Length) : 0), + LastGranuleExtra2 = (ushort)lastGranuleExtra, + CodebookHash = codebookHash, + SmallBlockSizeExponent = (byte)blockSize1Exponent, + LargeBlockSizeExponent = (byte)blockSize0Exponent, + }; + + AkdChunk? akdChunk = null; + if (EncodingSettings.GenerateAkdChunk) + akdChunk = BuildAkdChunk(perChannelSamples, channels, sampleRate); + + JunkChunk? junkChunk = null; + if (akdChunk != null) + { + var fmtDataSize = (int)FmtChunk.ChunkSize; + var akdDataSize = akdChunk.GetSize(); + junkChunk = BuildJunkChunkForAkd(fmtDataSize, akdDataSize); + } + + return new WemFile + { + FmtChunk = fmtChunk, + DataChunk = new DataChunk + { + SeekTable = seekTable, + SetupPacket = wemSetupData.SetupPacketBytes, + AudioPackets = wemAudioPackets, + }, + JunkChunk = junkChunk, + AkdChunk = akdChunk, + }; + } + + private AkdChunk BuildAkdChunk(float[][] perChannelSamples, int channels, int sampleRate) + { + // Measure integrated programme loudness using BS.1770 K-weighting with the standard absolute gate (-70 LUFS) + // and relative gate (-10 LU), then convert the LUFS delta to the linear gain stored in the AKD chunk. + var measuredLufs = MeasureBS1770Loudness(perChannelSamples, channels, sampleRate); + var gainDb = EncodingSettings.TargetLoudnessDb - measuredLufs; + var loudnessNormalisationGain = MathF.Pow(10.0f, gainDb / 20.0f); + + var channelCount = perChannelSamples.Length; + var downmixNormalisationGain = 1.0f; + if (channelCount > 2) + downmixNormalisationGain = MathF.Sqrt(2.0f / channelCount); + + var akdChunk = new AkdChunk + { + LoudnessNormalisationGain = loudnessNormalisationGain, + DownmixNormalisationGain = downmixNormalisationGain, + }; + + if (!EncodingSettings.IncludeHdrEnvelopeData) + return akdChunk; + + var monoMix = BuildMonoMix(perChannelSamples); + if (monoMix.Length == 0) + return akdChunk; + + var windowSize = Math.Max(64, EncodingSettings.HdrEnvelopeWindowSizeSamples); + var pointCount = (monoMix.Length + windowSize - 1) / windowSize; + var envelopeData = new byte[pointCount * AkdChunk.EnvelopePointSize]; + var peakRmsDb = DefaultSilenceDbFloor; + + for (var pointIndex = 0; pointIndex < pointCount; pointIndex++) + { + var sampleIndex = pointIndex * windowSize; + var samplesInWindow = Math.Min(windowSize, monoMix.Length - sampleIndex); + var windowRms = ComputeRms(monoMix, sampleIndex, samplesInWindow); + var windowDb = LinearToDb(windowRms); + peakRmsDb = Math.Max(peakRmsDb, windowDb); + + var quantisedDb = QuantiseEnvelopeDb(windowDb); + var envelopeByteOffset = pointIndex * (int)AkdChunk.EnvelopePointSize; + + var sampleIndexBytes = BitConverter.GetBytes((uint)sampleIndex); + envelopeData[envelopeByteOffset] = sampleIndexBytes[0]; + envelopeData[envelopeByteOffset + 1] = sampleIndexBytes[1]; + envelopeData[envelopeByteOffset + 2] = sampleIndexBytes[2]; + envelopeData[envelopeByteOffset + 3] = sampleIndexBytes[3]; + + var quantisedBytes = BitConverter.GetBytes(quantisedDb); + envelopeData[envelopeByteOffset + 4] = quantisedBytes[0]; + envelopeData[envelopeByteOffset + 5] = quantisedBytes[1]; + } + + akdChunk.HdrEnvelopePointCount = checked((uint)pointCount); + akdChunk.HdrPeakRms = peakRmsDb; + akdChunk.EnvelopeData = envelopeData; + + return akdChunk; + } + + private static JunkChunk BuildJunkChunkForAkd(int fmtDataSize, int akdDataSize) + { + var targetDataChunkPayloadOffset = 128; + var dataOffsetAlignment = 16; + var chunkHeaderSize = (int)RiffChunkHeader.HeaderSize; + var fmtPadding = fmtDataSize % RiffChunkHeader.ChunkPaddingAlignment == 0 ? 0 : 1; + var akdPadding = akdDataSize % RiffChunkHeader.ChunkPaddingAlignment == 0 ? 0 : 1; + + var bytesBeforeJunk = WemFileHeader.Size + chunkHeaderSize + fmtDataSize + fmtPadding + chunkHeaderSize; + var bytesAfterJunk = chunkHeaderSize + akdDataSize + akdPadding + chunkHeaderSize; + var minimumTargetOffset = Math.Max(targetDataChunkPayloadOffset, bytesBeforeJunk + bytesAfterJunk); + var alignedTargetOffset = ((minimumTargetOffset + dataOffsetAlignment - 1) / dataOffsetAlignment) * dataOffsetAlignment; + + return new JunkChunk { Padding = JunkChunk.CalculatePadding(alignedTargetOffset, bytesBeforeJunk, bytesAfterJunk) }; + } + + private static float MeasureBS1770Loudness(float[][] perChannelSamples, int channels, int sampleRate) + { + var coefficients = ComputeKWeightingCoefficients(sampleRate); + + // Channel weights per BS.1770: L/R/C = 1.0, LFE = 0.0, Ls/Rs = 1.41. + // For mono=1 and stereo=2 all channels are weight 1.0. + // For 5.1 (6ch): L R C LFE Ls Rs → weights 1,1,1,0,1.41,1.41 → sum contribution. + var channelWeights = GetBS1770ChannelWeights(channels); + + var filteredMeanSquare = new double[channels]; + for (var c = 0; c < channels; c++) + { + var filtered = ApplyBiquadCascade(perChannelSamples[c], coefficients.PreFilter, coefficients.RlbFilter); + var sumSquares = 0.0; + foreach (var s in filtered) sumSquares += s * s; + filteredMeanSquare[c] = sumSquares / filtered.Length; + } + + // Gating block size: 400 ms with 75% overlap (100 ms step). + var blockSamples = (int)(sampleRate * 0.400); + var stepSamples = (int)(sampleRate * 0.100); + if (blockSamples <= 0) + blockSamples = 1; + if (stepSamples <= 0) + stepSamples = 1; + + var sampleCount = perChannelSamples[0].Length; + var blocks = new List(); + + var filteredChannels = new double[channels][]; + for (var c = 0; c < channels; c++) + { + var filteredChannel = ApplyBiquadCascade(perChannelSamples[c], coefficients.PreFilter, coefficients.RlbFilter); + filteredChannels[c] = Array.ConvertAll(filteredChannel, x => (double)x); + } + + for (var start = 0; start + blockSamples <= sampleCount; start += stepSamples) + { + var blockLoudness = 0.0; + for (var c = 0; c < channels; c++) + { + var sumSquares = 0.0; + for (var i = start; i < start + blockSamples; i++) + sumSquares += filteredChannels[c][i] * filteredChannels[c][i]; + blockLoudness += channelWeights[c] * (sumSquares / blockSamples); + } + blocks.Add(blockLoudness); + } + + if (blocks.Count == 0) + return DefaultSilenceDbFloor; + + // Absolute BS.1770 gate: keep only blocks at or above -70 LUFS. + // In linear mean-square power this threshold is approximately 1e-7. + var absoluteGateLinear = 1e-7; + var absGated = blocks.Where(b => b >= absoluteGateLinear).ToList(); + if (absGated.Count == 0) + return DefaultSilenceDbFloor; + + // Relative BS.1770 gate: recompute the threshold from the absolute-gated + // programme power and keep blocks within 10 LU of that level. + var ungatedMean = absGated.Average(); + var relativeGateLinear = ungatedMean * Math.Pow(10.0, -1.0); // −10 LU = /10 in linear power + var relGated = absGated.Where(b => b >= relativeGateLinear).ToList(); + if (relGated.Count == 0) + return DefaultSilenceDbFloor; + + var integrated = relGated.Average(); + return (float)(-0.691 + 10.0 * Math.Log10(integrated)); + } + + private static KWeightingCoefficients ComputeKWeightingCoefficients(int sampleRate) + { + // Derive BS.1770 K-weighting filters from the analogue prototype with a + // bilinear transform so coefficients scale correctly with sample rate. + var sampleRateHz = (double)sampleRate; + + // High-shelf pre-filter (+4 dB around 1.68 kHz) + var preFilterShelfGain = 1.58489319246111; + var preFilterBandGain = 1.25849557432540; + var preFilterAngularFrequency = 2.0 * Math.PI * 1681.974450955533; + var preFilterQuality = 0.7071752369554196; + + var preFilterPreWarp = Math.Tan(preFilterAngularFrequency / (2.0 * sampleRateHz)); + var preFilterPreWarpSquared = preFilterPreWarp * preFilterPreWarp; + var preFilterDenominator = 1.0 + preFilterPreWarp / preFilterQuality + preFilterPreWarpSquared; + + var preFilter = new BiquadCoefficients( + (preFilterShelfGain + preFilterBandGain * preFilterPreWarp / preFilterQuality + preFilterPreWarpSquared) / preFilterDenominator, + 2.0 * (preFilterPreWarpSquared - preFilterShelfGain) / preFilterDenominator, + (preFilterShelfGain - preFilterBandGain * preFilterPreWarp / preFilterQuality + preFilterPreWarpSquared) / preFilterDenominator, + 2.0 * (preFilterPreWarpSquared - 1.0) / preFilterDenominator, + (1.0 - preFilterPreWarp / preFilterQuality + preFilterPreWarpSquared) / preFilterDenominator); + + // RLB high-pass at 38.1 Hz (second-order) + var rlbAngularFrequency = 2.0 * Math.PI * 38.13547087602444; + var rlbQuality = 0.5003270373238773; + + var rlbPreWarp = Math.Tan(rlbAngularFrequency / (2.0 * sampleRateHz)); + var rlbPreWarpSquared = rlbPreWarp * rlbPreWarp; + var rlbDenominator = 1.0 + rlbPreWarp / rlbQuality + rlbPreWarpSquared; + + var rlbFilter = new BiquadCoefficients( + 1.0 / rlbDenominator, + -2.0 / rlbDenominator, + 1.0 / rlbDenominator, + 2.0 * (rlbPreWarpSquared - 1.0) / rlbDenominator, + (1.0 - rlbPreWarp / rlbQuality + rlbPreWarpSquared) / rlbDenominator); + + return new KWeightingCoefficients(preFilter, rlbFilter); + } + + private static float[] ApplyBiquadCascade(float[] input, BiquadCoefficients firstStage, BiquadCoefficients secondStage) + { + var sampleCount = input.Length; + var firstStageOutput = new double[sampleCount]; + double firstStageDelay1 = 0; + double firstStageDelay2 = 0; + for (var i = 0; i < sampleCount; i++) + { + var inputSample = input[i]; + var firstStageSample = firstStage.B0 * inputSample + firstStageDelay1; + firstStageDelay1 = firstStage.B1 * inputSample - firstStage.A1 * firstStageSample + firstStageDelay2; + firstStageDelay2 = firstStage.B2 * inputSample - firstStage.A2 * firstStageSample; + firstStageOutput[i] = firstStageSample; + } + + var output = new float[sampleCount]; + double secondStageDelay1 = 0; + double secondStageDelay2 = 0; + for (var i = 0; i < sampleCount; i++) + { + var firstStageSample = firstStageOutput[i]; + var secondStageSample = secondStage.B0 * firstStageSample + secondStageDelay1; + secondStageDelay1 = secondStage.B1 * firstStageSample - secondStage.A1 * secondStageSample + secondStageDelay2; + secondStageDelay2 = secondStage.B2 * firstStageSample - secondStage.A2 * secondStageSample; + output[i] = (float)secondStageSample; + } + return output; + } + + private static double[] GetBS1770ChannelWeights(int channels) + { + // Mono + if (channels == 1) + return [1.0]; + + // Stereo (L, R) + if (channels == 2) + return [1.0, 1.0]; + + // 5.1 Surround (L, R, C, LFE, Ls, Rs) + if (channels == 6) + return [1.0, 1.0, 1.0, 0.0, 1.41, 1.41]; + + // 7.1 Surround (L, R, C, LFE, Ls, Rs, Lrs, Rrs) + if (channels == 8) + return [1.0, 1.0, 1.0, 0.0, 1.41, 1.41, 1.41, 1.41]; + + return Enumerable.Repeat(1.0, channels).ToArray(); + } + + private static float[] BuildMonoMix(float[][] perChannelSamples) + { + if (perChannelSamples.Length == 0) + return []; + + var sampleCount = perChannelSamples[0].Length; + var channelCount = perChannelSamples.Length; + var mono = new float[sampleCount]; + + for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) + { + var sum = 0.0f; + for (var channelIndex = 0; channelIndex < channelCount; channelIndex++) + sum += perChannelSamples[channelIndex][sampleIndex]; + mono[sampleIndex] = sum / channelCount; + } + + return mono; + } + + private static float ComputeRms(float[] samples, int startIndex, int count) + { + if (count <= 0) + return 0.0f; + + var sumSquares = 0.0; + for (var i = 0; i < count; i++) + { + var sample = samples[startIndex + i]; + sumSquares += sample * sample; + } + + return (float)Math.Sqrt(sumSquares / count); + } + + private static float LinearToDb(float linear) + { + if (linear <= 1e-12f) + return DefaultSilenceDbFloor; + return 20.0f * MathF.Log10(linear); + } + + private static ushort QuantiseEnvelopeDb(float db) + { + var clampedDb = Math.Clamp(db, DefaultSilenceDbFloor, 0.0f); + var attenuationDb = -clampedDb; + var normalised = attenuationDb / -DefaultSilenceDbFloor; + var quantised = (int)MathF.Round(normalised * ushort.MaxValue); + return (ushort)Math.Clamp(quantised, 0, ushort.MaxValue); + } + + private WemVorbisSetupData CompressSetup(byte[] standardSetupBytes, int channels) + { + var vorbisAsciiTagByteCount = 6; + var wwiseCodebookIdBitCount = 10; + var vorbisTimeDomainTransformTypeBitWidth = 16; + var input = new BitChunk(standardSetupBytes); + var output = new BitWriter(Math.Max(standardSetupBytes.Length / 4, 64)); + + input.ReadBits(8); + for (var headerIndex = 0; headerIndex < vorbisAsciiTagByteCount; headerIndex++) + input.ReadBits(BitHelper.BitsPerByte); + + var codebookCountField = input.ReadBits(8); + output.WriteBits(codebookCountField, 8); + var codebookCount = (int)codebookCountField + 1; + for (var codebookIndex = 0; codebookIndex < codebookCount; codebookIndex++) + { + var startBit = input.BitPosition; + SkipStandardVorbisCodebook(input); + var endBit = input.BitPosition; + var bitCount = endBit - startBit; + + var codebookBits = input.ReadBitRangeAsBytes(startBit, bitCount); + var libraryId = _codebookLibrary.FindLibraryId(codebookBits, bitCount); + output.WriteBits((uint)libraryId, wwiseCodebookIdBitCount); + } + + var timeCountField = input.ReadBits(6); + var timeCount = (int)timeCountField + 1; + for (var timeIndex = 0; timeIndex < timeCount; timeIndex++) + input.ReadBits(vorbisTimeDomainTransformTypeBitWidth); + + WwiseVorbisBitstreamTranscriber.TranscribeFloorConfigurationsVorbisToWwise(input, output); + WwiseVorbisBitstreamTranscriber.TranscribeResidueConfigurationsVorbisToWwise(input, output); + WwiseVorbisBitstreamTranscriber.TranscribeMappingConfigurationsVorbisToWwise(input, output, channels); + + var modeCountField = input.ReadBits(6); + output.WriteBits(modeCountField, 6); + var modeCount = (int)modeCountField + 1; + var modeBlockSizes = new bool[modeCount]; + for (var modeIndex = 0; modeIndex < modeCount; modeIndex++) + { + var blockSizeBit = input.ReadBits(1); + output.WriteBits(blockSizeBit, 1); + modeBlockSizes[modeIndex] = blockSizeBit != 0; + + var windowType = input.ReadBits(16); + var transformType = input.ReadBits(16); + if (windowType != 0 || transformType != 0) + throw new InvalidDataException("Wwise Vorbis only supports window/transform type 0; encoder emitted non-zero value."); + + output.WriteBits(input.ReadBits(8), 8); + } + + return new WemVorbisSetupData + { + SetupPacketBytes = output.ToArray(), + ModeConfiguration = new VorbisModeConfiguration + { + ModeBits = WwiseVorbisBitstreamTranscriber.IntegerLog(modeCount - 1), + ModeBlockSizes = modeBlockSizes, + }, + }; + } + + private static byte[] CompressAudioPacket(byte[] standardPacket, VorbisModeConfiguration modeInfo, ref bool previousPacketIsLargeBlock) + { + var audioPacketHeaderBitsConsumed = 1; + var terminalBitsForLongBlock = 2; + if (standardPacket.Length == 0) + return []; + + if (modeInfo.ModeBits == 0) + { + previousPacketIsLargeBlock = modeInfo.ModeBlockSizes.Length > 0 && modeInfo.ModeBlockSizes[0]; + return standardPacket; + } + + var input = new BitChunk(standardPacket); + var output = new BitWriter(standardPacket.Length); + var totalInputBits = standardPacket.Length * BitHelper.BitsPerByte; + var bitsConsumed = audioPacketHeaderBitsConsumed; + + input.ReadBits(1); + + uint modeNumber = 0; + if (modeInfo.ModeBits > 0) + { + modeNumber = input.ReadBits(modeInfo.ModeBits); + bitsConsumed += modeInfo.ModeBits; + } + + output.WriteBits(modeNumber, modeInfo.ModeBits); + var currentPacketIsLargeBlock = modeNumber < modeInfo.ModeBlockSizes.Length && modeInfo.ModeBlockSizes[modeNumber]; + if (currentPacketIsLargeBlock) + { + input.ReadBits(1); + input.ReadBits(1); + bitsConsumed += terminalBitsForLongBlock; + } + + var remainingBits = totalInputBits - bitsConsumed; + while (remainingBits >= BitHelper.BitsPerByte) + { + output.WriteBits(input.ReadBits(BitHelper.BitsPerByte), BitHelper.BitsPerByte); + remainingBits -= BitHelper.BitsPerByte; + } + + if (remainingBits > 0) + output.WriteBits(input.ReadBits(remainingBits), remainingBits); + + output.AlignToByte(); + previousPacketIsLargeBlock = currentPacketIsLargeBlock; + return output.ToArray(); + } + + private static List BuildSeekTable(List audioPackets, int largeBlockSize, int smallBlockSize, int totalSamples, int lastGranuleExtra, WemVorbisSetupData setupData) + { + var coupleGranuleDivisor = 4; + var seekEntries = new List(); + if (audioPackets.Count == 0) + return seekEntries; + + var modeInfo = setupData.ModeConfiguration; + var modeMask = modeInfo.ModeBits > 0 ? ((1 << modeInfo.ModeBits) - 1) : 0; + var seekInterval = largeBlockSize / 2; + var targetTotalGranule = totalSamples + lastGranuleExtra; + var granuleDeltas = new int[audioPackets.Count]; + var previousBlockSize = 0; + + for (var packetIndex = 0; packetIndex < audioPackets.Count; packetIndex++) + { + var packetData = audioPackets[packetIndex].Data; + var modeNumber = packetData.Length > 0 && modeInfo.ModeBits > 0 ? packetData[0] & modeMask : 0; + var currentBlockSize = modeNumber < modeInfo.ModeBlockSizes.Length && modeInfo.ModeBlockSizes[modeNumber] ? largeBlockSize : smallBlockSize; + granuleDeltas[packetIndex] = packetIndex == 0 ? 0 : (previousBlockSize + currentBlockSize) / coupleGranuleDivisor; + previousBlockSize = currentBlockSize; + } + + var pendingGranule = 0; + var pendingBytes = sizeof(ushort) + setupData.SetupPacketBytes.Length; + var emittedGranuleTotal = 0; + + for (var packetIndex = 0; packetIndex < audioPackets.Count; packetIndex++) + { + pendingGranule += granuleDeltas[packetIndex]; + pendingBytes += WemAudioPacket.LengthPrefixSize + audioPackets[packetIndex].Data.Length; + + var isLastPacket = packetIndex == audioPackets.Count - 1; + if (pendingGranule >= seekInterval || isLastPacket) + { + emittedGranuleTotal += pendingGranule; + if (isLastPacket && emittedGranuleTotal != targetTotalGranule) + pendingGranule -= emittedGranuleTotal - targetTotalGranule; + + seekEntries.Add(new WemSeekTableRecord { GranuleDelta = (ushort)pendingGranule, ByteCount = (ushort)pendingBytes }); + pendingGranule = 0; + pendingBytes = 0; + } + } + + return seekEntries; + } + + private static void SkipStandardVorbisCodebook(BitChunk input) + { + var sync = input.ReadBits(VorbisCodebook.SyncPatternBitWidth); + if (sync != VorbisCodebook.SyncPattern) + throw new InvalidDataException($"Standard Vorbis codebook sync pattern was 0x{sync:X6} (expected 0x{VorbisCodebook.SyncPattern:X6})."); + + var dimensions = (int)input.ReadBits(VorbisCodebook.DimensionsBitWidth); + var entries = (int)input.ReadBits(VorbisCodebook.EntriesBitWidth); + var ordered = input.ReadBits(1); + if (ordered != 0) + { + input.ReadBits(5); + var currentEntry = 0; + while (currentEntry < entries) + { + var bits = WwiseVorbisBitstreamTranscriber.IntegerLog(entries - currentEntry); + var number = (int)input.ReadBits(bits); + currentEntry += number; + } + + if (currentEntry > entries) + throw new InvalidDataException("Standard Vorbis codebook entry count overflowed."); + } + else + { + var sparse = input.ReadBits(1); + for (var entryIndex = 0; entryIndex < entries; entryIndex++) + { + var present = true; + if (sparse != 0) + present = input.ReadBits(1) != 0; + if (present) + input.ReadBits(5); + } + } + + var lookupType = input.ReadBits(4); + if (lookupType == 0) + return; + + if (lookupType != 1) + throw new InvalidDataException($"Standard Vorbis codebook uses unsupported lookup type {lookupType}."); + + input.ReadBits(VorbisCodebook.VqFloatFieldBitWidth); + input.ReadBits(VorbisCodebook.VqFloatFieldBitWidth); + var valueLength = (int)input.ReadBits(VorbisCodebook.ValueLengthBitWidth); + input.ReadBits(1); + + var quantValueCount = WwiseVorbisBitstreamTranscriber.ComputeMapType1QuantValues(entries, dimensions); + for (var i = 0; i < quantValueCount; i++) + input.ReadBits(valueLength + 1); + } + + private static float[][] ConvertPcmToPerChannelFloat(PcmAudio audio, ushort formatTag) + { + const ushort IeeeFloatFormatTag = 3; + const int Pcm8BitDepth = 8; + const int Pcm16BitDepth = 16; + const int Pcm24BitDepth = 24; + const int Pcm32BitDepth = 32; + const float Pcm16BitNormalizationDivisor = 32768.0f; + const float Pcm24BitNormalizationDivisor = 8388608.0f; + const float Pcm32BitNormalizationDivisor = 2147483648.0f; + const int Pcm8BitMidpoint = 128; + const float Pcm8BitNormalizationDivisor = 128.0f; + + var channels = audio.Channels; + var sampleCount = audio.SampleCount; + var bytesPerSample = audio.BitsPerSample / BitHelper.BitsPerByte; + + var perChannelSamples = new float[channels][]; + for (var channelIndex = 0; channelIndex < channels; channelIndex++) + perChannelSamples[channelIndex] = new float[sampleCount]; + + for (var sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) + { + for (var channelIndex = 0; channelIndex < channels; channelIndex++) + { + var byteOffset = (sampleIndex * channels + channelIndex) * bytesPerSample; + float rawSample; + + if (audio.BitsPerSample == Pcm16BitDepth) + rawSample = (short)(audio.Data[byteOffset] | (audio.Data[byteOffset + 1] << BitHelper.BitsPerByte)) / Pcm16BitNormalizationDivisor; + else if (audio.BitsPerSample == Pcm8BitDepth) + rawSample = (audio.Data[byteOffset] - Pcm8BitMidpoint) / Pcm8BitNormalizationDivisor; + else if (audio.BitsPerSample == Pcm24BitDepth) + { + var sample24 = audio.Data[byteOffset] | (audio.Data[byteOffset + 1] << BitHelper.BitsPerByte) | (audio.Data[byteOffset + 2] << 16); + if ((sample24 & 0x800000) != 0) + sample24 |= unchecked((int)0xFF000000); + + rawSample = sample24 / Pcm24BitNormalizationDivisor; + } + else if (audio.BitsPerSample == Pcm32BitDepth) + { + if (formatTag == IeeeFloatFormatTag) + rawSample = BitConverter.ToSingle(audio.Data, byteOffset); + else + rawSample = BitConverter.ToInt32(audio.Data, byteOffset) / Pcm32BitNormalizationDivisor; + } + else + throw new InvalidDataException($"Unsupported WAV bits per sample: {audio.BitsPerSample}. Supported formats are 8-bit, 16-bit, 24-bit, and 32-bit PCM, plus 32-bit IEEE float."); + + perChannelSamples[channelIndex][sampleIndex] = rawSample; + } + } + + return perChannelSamples; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisSetupData.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisSetupData.cs new file mode 100644 index 000000000..8ce31e89d --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisSetupData.cs @@ -0,0 +1,8 @@ +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WemVorbisSetupData + { + public byte[] SetupPacketBytes { get; set; } = []; + public VorbisModeConfiguration ModeConfiguration { get; set; } = new(); + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WwiseCodebookLibrary.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WwiseCodebookLibrary.cs new file mode 100644 index 000000000..b2dec93dd --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WwiseCodebookLibrary.cs @@ -0,0 +1,152 @@ +using System.Buffers.Binary; +using Shared.ByteParsing; +using Shared.EmbeddedResources; + +namespace Shared.GameFormats.Wwise.Wem.V132.Encoding +{ + public class WwiseCodebookLibrary + { + private readonly byte[] _packedCodebooks; + private readonly int[] _codebookOffsets; + private readonly Dictionary _libraryIdByCodebookBits; + + public WwiseCodebookLibrary() + { + _packedCodebooks = ResourceLoader.LoadBytes("Resources.Wwise.packed_codebooks_aoTuV_603.bin"); + _codebookOffsets = ParseCodebookOffsets(_packedCodebooks); + _libraryIdByCodebookBits = BuildLibraryIdLookup(); + } + + public int LibraryCount => Math.Max(0, _codebookOffsets.Length - 1); + + public VorbisCodebook GetCodebook(int codebookId) + { + var packed = GetPackedEntry(codebookId); + var writer = new BitWriter(Math.Max(packed.Length * 4, 64)); + TranscribeFromPackedEntry(writer, packed); + return new VorbisCodebook(writer.ToArray(), writer.BitPosition); + } + + public int FindLibraryId(byte[] standardCodebookBits, int bitCount) + { + var fingerprint = BuildFingerprint(standardCodebookBits, bitCount); + if (_libraryIdByCodebookBits.TryGetValue(fingerprint, out var matchedId)) + return matchedId; + + throw new InvalidDataException($"Vorbis codebook with bitCount {bitCount} was not found"); + } + + private ReadOnlySpan GetPackedEntry(int codebookId) + { + if (codebookId < 0 || codebookId + 1 >= _codebookOffsets.Length) + throw new InvalidDataException($"Wwise Vorbis codebook {codebookId} was not found in the codebook library."); + + var startOffset = _codebookOffsets[codebookId]; + var endOffset = _codebookOffsets[codebookId + 1]; + if (startOffset < 0 || endOffset < startOffset || endOffset > _packedCodebooks.Length) + throw new InvalidDataException($"Wwise Vorbis codebook {codebookId} has an invalid packed offset range."); + + return _packedCodebooks.AsSpan(startOffset, endOffset - startOffset); + } + + private Dictionary BuildLibraryIdLookup() + { + var lookup = new Dictionary(LibraryCount); + for (var codebookId = 0; codebookId < LibraryCount; codebookId++) + { + var codebook = GetCodebook(codebookId); + lookup[BuildFingerprint(codebook.Data, codebook.BitCount)] = codebookId; + } + return lookup; + } + + private static int[] ParseCodebookOffsets(byte[] data) + { + var tableOffset = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(data.Length - 4, 4)); + var count = (data.Length - tableOffset) / 4; + var offsets = new int[count]; + + for (var index = 0; index < count; index++) + offsets[index] = (int)BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(tableOffset + index * 4, 4)); + + return offsets; + } + + private static string BuildFingerprint(byte[] bits, int bitCount) => bitCount.ToString() + ':' + Convert.ToHexString(bits); + + private static void TranscribeFromPackedEntry(BitWriter output, ReadOnlySpan codebookBytes) + { + var input = new BitChunk(codebookBytes.ToArray()); + output.WriteBits(VorbisCodebook.SyncPattern, VorbisCodebook.SyncPatternBitWidth); + + var dimensions = (int)input.ReadBits(4); + output.WriteBits((uint)dimensions, VorbisCodebook.DimensionsBitWidth); + + var entries = (int)input.ReadBits(14); + output.WriteBits((uint)entries, VorbisCodebook.EntriesBitWidth); + + var ordered = input.ReadBits(1); + output.WriteBits(ordered, 1); + if (ordered != 0) + { + var initialCodewordLength = input.ReadBits(5); + output.WriteBits(initialCodewordLength, 5); + + var currentEntry = 0; + while (currentEntry < entries) + { + var bits = WwiseVorbisBitstreamTranscriber.IntegerLog(entries - currentEntry); + var number = (int)input.ReadBits(bits); + output.WriteBits((uint)number, bits); + currentEntry += number; + } + + if (currentEntry > entries) + throw new InvalidDataException("Wwise Vorbis codebook entry count exceeded the declared size."); + } + else + { + var codewordLengthLength = (int)input.ReadBits(3); + var sparse = input.ReadBits(1); + output.WriteBits(sparse, 1); + if (codewordLengthLength is < 1 or > 5) + throw new InvalidDataException("Wwise Vorbis codebook codeword length field width is invalid."); + + for (var entryIndex = 0; entryIndex < entries; entryIndex++) + { + var present = true; + if (sparse != 0) + { + present = input.ReadBits(1) != 0; + output.WriteBits(present ? 1u : 0u, 1); + } + + if (!present) + continue; + + output.WriteBits(input.ReadBits(codewordLengthLength), 5); + } + } + + var lookupType = input.ReadBits(1); + output.WriteBits(lookupType, 4); + + if (lookupType == 0) + return; + + if (lookupType != 1) + throw new InvalidDataException("Wwise Vorbis codebook uses an unsupported lookup type."); + + output.WriteBits(input.ReadBits(VorbisCodebook.VqFloatFieldBitWidth), VorbisCodebook.VqFloatFieldBitWidth); + output.WriteBits(input.ReadBits(VorbisCodebook.VqFloatFieldBitWidth), VorbisCodebook.VqFloatFieldBitWidth); + + var valueLength = input.ReadBits(VorbisCodebook.ValueLengthBitWidth); + output.WriteBits(valueLength, VorbisCodebook.ValueLengthBitWidth); + output.WriteBits(input.ReadBits(1), 1); + + var quantisedValueCount = WwiseVorbisBitstreamTranscriber.ComputeMapType1QuantValues(entries, dimensions); + for (var quantisedIndex = 0; quantisedIndex < quantisedValueCount; quantisedIndex++) + output.WriteBits(input.ReadBits((int)valueLength + 1), (int)valueLength + 1); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/FmtChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/FmtChunk.cs new file mode 100644 index 000000000..0622761d8 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/FmtChunk.cs @@ -0,0 +1,133 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + + // The fmt chunk contains stream format metadata and Wwise Vorbis extension fields. + public class FmtChunk : RiffChunk + { + // Chunk tags are always exactly 4 bytes so tags shorter than 4 characters are padded with a trailing space. + public const string ChunkTag = "fmt "; + + private const ushort WwiseVorbisFormatTag = 0xFFFF; + private const int WwiseFmtExtraDataSize = 0x30; + private const uint WwiseReservedFieldValue = 0x00008000u; + public const uint ChunkSize = 66; + + // Standard WAVEFORMATEX + public ushort FormatTag { get; set; } + public ushort Channels { get; set; } + public uint SampleRate { get; set; } + public uint AverageBytesPerSecond { get; set; } + public ushort BlockAlign { get; set; } + public ushort BitsPerSample { get; set; } + public ushort ExtraDataSize { get; set; } + public ushort VorbisExtra { get; set; } + + // Wwise Vorbis extension + public uint ChannelMask { get; set; } + public int SampleCount { get; set; } + public uint SetupPacketSize { get; set; } + public uint AudioDataOffset { get; set; } + public ushort Reserved0 { get; set; } + public ushort LastGranuleExtra { get; set; } + public uint SeekTableSize { get; set; } + public uint FirstAudioPacketOffset { get; set; } + public ushort MaxPacketSize { get; set; } + public ushort LastGranuleExtra2 { get; set; } + public uint Reserved1 { get; set; } + public uint Reserved2 { get; set; } + public uint CodebookHash { get; set; } + public byte SmallBlockSizeExponent { get; set; } + public byte LargeBlockSizeExponent { get; set; } + + public FmtChunk() + { + Tag = ChunkTag; + FormatTag = WwiseVorbisFormatTag; + BlockAlign = 0; + BitsPerSample = 0; + ExtraDataSize = WwiseFmtExtraDataSize; + VorbisExtra = 0; + Reserved0 = 0; + Reserved1 = WwiseReservedFieldValue; + Reserved2 = WwiseReservedFieldValue; + } + + public override void ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < ChunkSize) + throw new InvalidDataException($"WEM fmt chunk must be at least {ChunkSize} bytes."); + + FormatTag = chunk.ReadUShort(); + if (FormatTag != WwiseVorbisFormatTag) + throw new InvalidDataException($"WEM format tag 0x{FormatTag:X4} is not supported."); + + Channels = chunk.ReadUShort(); + SampleRate = chunk.ReadUInt32(); + AverageBytesPerSecond = chunk.ReadUInt32(); + BlockAlign = chunk.ReadUShort(); + BitsPerSample = chunk.ReadUShort(); + ExtraDataSize = chunk.ReadUShort(); + + if (ExtraDataSize != WwiseFmtExtraDataSize) + throw new InvalidDataException($"WEM Vorbis extra data size 0x{ExtraDataSize:X} is not supported. Expected 0x{WwiseFmtExtraDataSize:X}."); + + VorbisExtra = chunk.ReadUShort(); + ChannelMask = chunk.ReadUInt32(); + + var v132Marker = BitHelper.ExtractBits(ChannelMask, 8, 4); + if (v132Marker == 0) + throw new InvalidDataException("Only Wwise Vorbis V132 is supported. The V132 marker in the fmt chunk is not present."); + + SampleCount = chunk.ReadInt32(); + SetupPacketSize = chunk.ReadUInt32(); + AudioDataOffset = chunk.ReadUInt32(); + Reserved0 = chunk.ReadUShort(); + LastGranuleExtra = chunk.ReadUShort(); + SeekTableSize = chunk.ReadUInt32(); + FirstAudioPacketOffset = chunk.ReadUInt32(); + MaxPacketSize = chunk.ReadUShort(); + LastGranuleExtra2 = chunk.ReadUShort(); + Reserved1 = chunk.ReadUInt32(); + Reserved2 = chunk.ReadUInt32(); + CodebookHash = chunk.ReadUInt32(); + SmallBlockSizeExponent = chunk.ReadByte(); + LargeBlockSizeExponent = chunk.ReadByte(); + } + + public override byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.UShort.EncodeValue(FormatTag, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(Channels, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SampleRate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(AverageBytesPerSecond, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(BlockAlign, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(BitsPerSample, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(ExtraDataSize, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(VorbisExtra, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(ChannelMask, out _)); + stream.Write(ByteParsers.Int32.EncodeValue(SampleCount, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SetupPacketSize, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(AudioDataOffset, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(Reserved0, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(LastGranuleExtra, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SeekTableSize, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(FirstAudioPacketOffset, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(MaxPacketSize, out _)); + stream.Write(ByteParsers.UShort.EncodeValue(LastGranuleExtra2, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(Reserved1, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(Reserved2, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(CodebookHash, out _)); + stream.WriteByte(SmallBlockSizeExponent); + stream.WriteByte(LargeBlockSizeExponent); + + var byteArray = stream.ToArray(); + var sanityReload = new FmtChunk(); + sanityReload.ReadChunk(new ByteChunk(byteArray)); + + return byteArray; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/JunkChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/JunkChunk.cs new file mode 100644 index 000000000..49e31ef80 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/JunkChunk.cs @@ -0,0 +1,33 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + // The JUNK chunk contains padding bytes used for chunk / data alignment. + public class JunkChunk : RiffChunk + { + public const string ChunkTag = "JUNK"; + + public byte[] Padding { get; set; } = []; + + public JunkChunk() + { + Tag = ChunkTag; + } + + public override void ReadData(ByteChunk chunk) + { + Padding = chunk.ReadBytes(chunk.BytesLeft); + } + + public override byte[] WriteData() => Padding; + + public static byte[] CalculatePadding(int targetDataChunkOffsetBytes, int bytesBeforeJunk, int bytesAfterJunk) + { + if (targetDataChunkOffsetBytes <= 0) + return []; + + var junkSize = targetDataChunkOffsetBytes - bytesBeforeJunk - bytesAfterJunk; + return junkSize > 0 ? new byte[junkSize] : []; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/RiffChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/RiffChunk.cs new file mode 100644 index 000000000..c25189617 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/RiffChunk.cs @@ -0,0 +1,35 @@ +using Serilog; +using Shared.ByteParsing; +using Shared.Core.ErrorHandling; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public abstract class RiffChunk + { + readonly ILogger _logger = Logging.Create(); + + public const byte PaddingByteValue = 0; + + public string Tag { get; set; } = string.Empty; + public uint ByteIndexInFile { get; set; } + + public abstract void ReadData(ByteChunk chunk); + + public abstract byte[] WriteData(); + + public void ReadChunk(ByteChunk chunk) + { + try + { + ReadData(chunk); + } + catch (Exception e) + { + _logger.Here().Error($"Failed to parse chunk '{Tag}' at byte index {ByteIndexInFile} - " + e.Message); + throw; + } + } + + public static void WritePadding(Stream stream) => stream.WriteByte(PaddingByteValue); + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/RiffChunkFactory.cs b/Shared/GameFiles/Wwise/Wem/V132/RiffChunkFactory.cs new file mode 100644 index 000000000..eef17ef9d --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/RiffChunkFactory.cs @@ -0,0 +1,28 @@ +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + + public static class RiffChunkFactory + { + private static readonly Dictionary> s_chunkFactories = BuildChunkFactories(); + + private static Dictionary> BuildChunkFactories() + { + return new Dictionary> + { + [FmtChunk.ChunkTag] = static () => new FmtChunk(), + [DataChunk.ChunkTag] = static () => new DataChunk(), + [JunkChunk.ChunkTag] = static () => new JunkChunk(), + [CueChunk.ChunkTag] = static () => new CueChunk(), + [AkdChunk.ChunkTag] = static () => new AkdChunk(), + }; + } + + public static RiffChunk CreateChunk(string tag) + { + if (s_chunkFactories.TryGetValue(tag, out var factory)) + return factory(); + + return new UnknownChunk { Tag = tag }; + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/RiffChunkHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/RiffChunkHeader.cs new file mode 100644 index 000000000..a06763481 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/RiffChunkHeader.cs @@ -0,0 +1,43 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public class RiffChunkHeader(string tag, uint chunkSize) + { + public const uint HeaderSize = 8; + public const int ChunkPaddingAlignment = 2; + + public string Tag { get; set; } = tag; + public uint ChunkSize { get; set; } = chunkSize; + + public static RiffChunkHeader ReadData(ByteChunk chunk) + { + var tag = System.Text.Encoding.ASCII.GetString(chunk.ReadBytes(4)); + var chunkSize = chunk.ReadUInt32(); + return new RiffChunkHeader(tag, chunkSize); + } + + public static RiffChunkHeader PeekFromBytes(ByteChunk chunk) + { + var peekBytes = chunk.PeekChunk((int)HeaderSize); + return ReadData(peekBytes); + } + + public static byte[] WriteData(RiffChunkHeader header) + { + if (header.Tag.Length != 4) + throw new Exception($"Header not valid {header.Tag}"); + + using var stream = new MemoryStream(); + stream.Write(ByteParsers.Byte.EncodeValue((byte)header.Tag[0], out _)); + stream.Write(ByteParsers.Byte.EncodeValue((byte)header.Tag[1], out _)); + stream.Write(ByteParsers.Byte.EncodeValue((byte)header.Tag[2], out _)); + stream.Write(ByteParsers.Byte.EncodeValue((byte)header.Tag[3], out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(header.ChunkSize, out _)); + + var byteArray = stream.ToArray(); + return byteArray; + } + + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/UnknownChunk.cs b/Shared/GameFiles/Wwise/Wem/V132/UnknownChunk.cs new file mode 100644 index 000000000..f781a9584 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/UnknownChunk.cs @@ -0,0 +1,16 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public class UnknownChunk : RiffChunk + { + public byte[] Data { get; set; } = []; + + public override void ReadData(ByteChunk chunk) + { + Data = chunk.ReadBytes(chunk.BytesLeft); + } + + public override byte[] WriteData() => throw new NotImplementedException("Writing is not supported."); + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs b/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs new file mode 100644 index 000000000..b238c68e2 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs @@ -0,0 +1,173 @@ +using Shared.ByteParsing; +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public class WemFile + { + public WemFileHeader Header { get; set; } = new WemFileHeader(0); + public FmtChunk FmtChunk { get; set; } = new FmtChunk(); + public DataChunk DataChunk { get; set; } = new DataChunk(); + public JunkChunk? JunkChunk { get; set; } + public AkdChunk? AkdChunk { get; set; } + public CueChunk? CueChunk { get; set; } + public List UnknownChunks { get; set; } = []; + + public static WemFile CreateFromBytes(byte[] wemData) + { + ArgumentNullException.ThrowIfNull(wemData); + var wemFile = new WemFile(); + wemFile.ReadData(new ByteChunk(wemData)); + return wemFile; + } + + public static WemFile CreateFromWavBytes(byte[] wavData, WemEncodingSettings? encodingSettings = null) + { + ArgumentNullException.ThrowIfNull(wavData); + var codebookLibrary = new WwiseCodebookLibrary(); + var encoder = new WemVorbisEncoder(codebookLibrary); + if (encodingSettings != null) + encoder.EncodingSettings = encodingSettings; + + return encoder.EncodeFromWav(wavData); + } + + public void ReadData(ByteChunk chunk) + { + Header = WemFileHeader.ReadData(chunk); + + var hasFmt = false; + var hasData = false; + + while (chunk.BytesLeft >= RiffChunkHeader.HeaderSize) + { + var chunkStartIndex = chunk.Index; + var chunkHeader = RiffChunkHeader.ReadData(chunk); + var tag = chunkHeader.Tag; + var size = chunkHeader.ChunkSize; + + if (size > (uint)chunk.BytesLeft) + throw new InvalidDataException($"Chunk {tag} claims size {size} but only {chunk.BytesLeft} bytes remaining."); + + var chunkData = chunk.ReadBytes((int)size); + var byteChunk = new ByteChunk(chunkData); + + var riffChunk = RiffChunkFactory.CreateChunk(tag); + if (riffChunk is not UnknownChunk && riffChunk.Tag != tag) + throw new InvalidDataException($"Chunk factory returned '{riffChunk.Tag}' for file chunk '{tag}'."); + + riffChunk.ByteIndexInFile = (uint)chunkStartIndex; + + if (riffChunk is DataChunk dataChunk) + { + if (!hasFmt) + throw new InvalidDataException("WEM data chunk must appear after the fmt chunk."); + + dataChunk.FmtChunk = FmtChunk; + } + + riffChunk.ReadChunk(byteChunk); + + if (tag == FmtChunk.ChunkTag) + { + if (riffChunk is FmtChunk fmt) + { + FmtChunk = fmt; + hasFmt = true; + } + } + else if (tag == DataChunk.ChunkTag) + { + if (!hasFmt) + throw new InvalidDataException("WEM data chunk must appear after the fmt chunk."); + + if (riffChunk is DataChunk data) + { + DataChunk = data; + hasData = true; + } + } + else if (tag == JunkChunk.ChunkTag) + JunkChunk = riffChunk as JunkChunk; + else if (tag == AkdChunk.ChunkTag) + AkdChunk = riffChunk as AkdChunk; + else if (tag == CueChunk.ChunkTag) + CueChunk = riffChunk as CueChunk; + else + { + if (riffChunk is UnknownChunk unknown) + UnknownChunks.Add(unknown); + } + + // Padding + if (size % RiffChunkHeader.ChunkPaddingAlignment != 0) + chunk.Advance(1); + } + + if (!hasFmt || !hasData) + throw new InvalidDataException("WEM file must contain both a fmt chunk and a data chunk."); + } + + public byte[] WriteData() + { + if (CueChunk != null) + throw new NotSupportedException("Writing WEM files with a cue chunk is not supported."); + if (UnknownChunks.Count > 0) + throw new NotSupportedException($"Writing WEM files with unknown chunks is not supported: {string.Join(", ", UnknownChunks.Select(c => c.Tag))}."); + + using var stream = new MemoryStream(); + + var riffHeaderPosition = stream.Position; + + Header.RiffSize = 0u; + WemFileHeader.WriteData(stream, Header); + + var fmtData = FmtChunk.WriteData(); + var fmtChunkHeader = new RiffChunkHeader(FmtChunk.Tag, (uint)fmtData.Length); + var fmtChunkHeaderData = RiffChunkHeader.WriteData(fmtChunkHeader); + stream.Write(fmtChunkHeaderData); + stream.Write(fmtData); + if (fmtData.Length % RiffChunkHeader.ChunkPaddingAlignment != 0) + RiffChunk.WritePadding(stream); + + if (AkdChunk != null) + { + if (JunkChunk != null) + { + var junkData = JunkChunk.WriteData(); + + var junkChunkHeader = new RiffChunkHeader(JunkChunk.ChunkTag, (uint)junkData.Length); + var junkChunkHeaderData = RiffChunkHeader.WriteData(junkChunkHeader); + stream.Write(junkChunkHeaderData); + stream.Write(junkData); + if (junkData.Length % RiffChunkHeader.ChunkPaddingAlignment != 0) + RiffChunk.WritePadding(stream); + } + + var akdData = AkdChunk.WriteData(); + var akdChunkHeader = new RiffChunkHeader(AkdChunk.Tag, (uint)akdData.Length); + var akdChunkHeaderData = RiffChunkHeader.WriteData(akdChunkHeader); + stream.Write(akdChunkHeaderData); + stream.Write(akdData); + if (akdData.Length % RiffChunkHeader.ChunkPaddingAlignment != 0) + RiffChunk.WritePadding(stream); + } + + var dataData = DataChunk.WriteData(); + var dataChunkHeader = new RiffChunkHeader(DataChunk.Tag, (uint)dataData.Length); + var dataChunkHeaderData = RiffChunkHeader.WriteData(dataChunkHeader); + stream.Write(dataChunkHeaderData); + stream.Write(dataData); + if (dataData.Length % RiffChunkHeader.ChunkPaddingAlignment != 0) + RiffChunk.WritePadding(stream); + + // Update RIFF size + var totalSize = (uint)(stream.Length - WemFileHeader.BytesBeforeSize); + Header.RiffSize = totalSize; + stream.Position = riffHeaderPosition + 4; + stream.Write(BitConverter.GetBytes(totalSize)); + + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/WemFileHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/WemFileHeader.cs new file mode 100644 index 000000000..f8b1ee91a --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/WemFileHeader.cs @@ -0,0 +1,49 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public class WemFileHeader(uint riffSize) + { + public const int Size = 12; + public const int BytesBeforeSize = 8; + public const string RiffContainerId = "RIFF"; + public const string WaveFormType = "WAVE"; + + public string ContainerId { get; set; } = RiffContainerId; + public string FormType { get; set; } = WaveFormType; + public uint RiffSize { get; set; } = riffSize; + + public static WemFileHeader ReadData(ByteChunk chunk) + { + var containerId = System.Text.Encoding.ASCII.GetString(chunk.ReadBytes(4)); + if (containerId != RiffContainerId) + throw new InvalidDataException($"Expected RIFF container ID '{RiffContainerId}', got '{containerId}'."); + + var riffSize = chunk.ReadUInt32(); + + var formType = System.Text.Encoding.ASCII.GetString(chunk.ReadBytes(4)); + if (formType != WaveFormType) + throw new InvalidDataException($"Expected WAVE form type '{WaveFormType}', got '{formType}'."); + + var header = new WemFileHeader(riffSize); + + return header; + } + + public static void WriteData(Stream stream, WemFileHeader header) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(header); + + if (header.ContainerId.Length != 4) + throw new InvalidDataException($"RIFF container id must be 4 bytes, got '{header.ContainerId}'."); + + if (header.FormType.Length != 4) + throw new InvalidDataException($"RIFF form type must be 4 bytes, got '{header.FormType}'."); + + stream.Write(System.Text.Encoding.ASCII.GetBytes(header.ContainerId)); + stream.Write(BitConverter.GetBytes(header.RiffSize)); + stream.Write(System.Text.Encoding.ASCII.GetBytes(header.FormType)); + } + } +} diff --git a/Shared/GameFiles/Wwise/Wem/V132/WwiseVorbisBitstreamTranscriber.cs b/Shared/GameFiles/Wwise/Wem/V132/WwiseVorbisBitstreamTranscriber.cs new file mode 100644 index 000000000..7ba88eec7 --- /dev/null +++ b/Shared/GameFiles/Wwise/Wem/V132/WwiseVorbisBitstreamTranscriber.cs @@ -0,0 +1,369 @@ +using Shared.ByteParsing; + +namespace Shared.GameFormats.Wwise.Wem.V132 +{ + public static class WwiseVorbisBitstreamTranscriber + { + public const int VorbisFloorType1 = 1; + public const int VorbisFloorTypeBitWidth = 16; + public const int VorbisMappingType0 = 0; + public const int VorbisMappingTypeBitWidth = 16; + public const int VorbisResidueTypeBitWidth = 16; + public const int WwiseResidueTypeBitWidth = 2; + public const int SupportedResidueTypeMaximum = 2; + + private const int ConfigurationCountFieldBitWidth = 6; + + public static int ComputeMapType1QuantValues(int entries, int dimensions) + { + var bits = IntegerLog(entries); + var values = entries >> ((bits - 1) * (dimensions - 1) / dimensions); + + var maxIterations = entries + dimensions + 2; + while (maxIterations-- > 0) + { + ulong current = 1; + ulong next = 1; + for (var dimensionIndex = 0; dimensionIndex < dimensions; dimensionIndex++) + { + current *= (uint)values; + next *= (uint)(values + 1); + } + + if (current <= (ulong)entries && next > (ulong)entries) + return values; + + values += current > (ulong)entries ? -1 : 1; + } + + throw new InvalidDataException("ComputeMapType1QuantValues failed to converge."); + } + + public static int IntegerLog(int value) + { + var result = 0; + while (value > 0) + { + result++; + value >>= 1; + } + return result; + } + + public static int TranscribeFloorConfigurationsVorbisToWwise(BitChunk input, BitWriter output) + { + var floorCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(floorCountField, ConfigurationCountFieldBitWidth); + var floorCount = (int)floorCountField + 1; + for (var floorIndex = 0; floorIndex < floorCount; floorIndex++) + TranscribeFloorConfigurationVorbisToWwise(input, output); + return floorCount; + } + + public static void TranscribeFloorConfigurationVorbisToWwise(BitChunk input, BitWriter output) + { + var floorType = input.ReadBits(VorbisFloorTypeBitWidth); + if (floorType != VorbisFloorType1) + throw new InvalidDataException($"Wwise Vorbis only supports floor type 1; encoder emitted {floorType}."); + + TranscribeFloor1Body(input, output); + } + + public static int TranscribeResidueConfigurationsVorbisToWwise(BitChunk input, BitWriter output) + { + var residueCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(residueCountField, ConfigurationCountFieldBitWidth); + var residueCount = (int)residueCountField + 1; + for (var residueIndex = 0; residueIndex < residueCount; residueIndex++) + TranscribeResidueConfigurationVorbisToWwise(input, output); + return residueCount; + } + + public static void TranscribeResidueConfigurationVorbisToWwise(BitChunk input, BitWriter output) + { + var residueType = input.ReadBits(VorbisResidueTypeBitWidth); + if (residueType > SupportedResidueTypeMaximum) + throw new InvalidDataException($"Wwise Vorbis residue type must be 0..2 (got {residueType})."); + + output.WriteBits(residueType, WwiseResidueTypeBitWidth); + TranscribeResidueBody(input, output); + } + + public static int TranscribeMappingConfigurationsVorbisToWwise(BitChunk input, BitWriter output, int channelCount) + { + var mappingCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(mappingCountField, ConfigurationCountFieldBitWidth); + var mappingCount = (int)mappingCountField + 1; + for (var mappingIndex = 0; mappingIndex < mappingCount; mappingIndex++) + TranscribeMappingConfigurationVorbisToWwise(input, output, channelCount); + return mappingCount; + } + + public static void TranscribeMappingConfigurationVorbisToWwise(BitChunk input, BitWriter output, int channelCount) + { + var mappingType = input.ReadBits(VorbisMappingTypeBitWidth); + if (mappingType != VorbisMappingType0) + throw new InvalidDataException($"Wwise Vorbis only supports mapping type 0 (got {mappingType})."); + + TranscribeMappingBody(input, output, channelCount); + } + + public static int TranscribeFloorConfigurationsWwiseToVorbis(BitChunk input, BitWriter output, int codebookCount) + { + var floorCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(floorCountField, ConfigurationCountFieldBitWidth); + var floorCount = (int)floorCountField + 1; + for (var floorIndex = 0; floorIndex < floorCount; floorIndex++) + TranscribeFloorConfigurationWwiseToVorbis(input, output, codebookCount); + return floorCount; + } + + public static void TranscribeFloorConfigurationWwiseToVorbis(BitChunk input, BitWriter output, int codebookCount) + { + output.WriteBits((uint)VorbisFloorType1, VorbisFloorTypeBitWidth); + TranscribeFloor1Body(input, output, codebookCount); + } + + public static int TranscribeResidueConfigurationsWwiseToVorbis(BitChunk input, BitWriter output, int codebookCount) + { + var residueCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(residueCountField, ConfigurationCountFieldBitWidth); + + var residueCount = (int)residueCountField + 1; + for (var residueIndex = 0; residueIndex < residueCount; residueIndex++) + TranscribeResidueConfigurationWwiseToVorbis(input, output, codebookCount); + + return residueCount; + } + + public static void TranscribeResidueConfigurationWwiseToVorbis(BitChunk input, BitWriter output, int codebookCount) + { + var residueType = input.ReadBits(WwiseResidueTypeBitWidth); + if (residueType > SupportedResidueTypeMaximum) + throw new InvalidDataException("Wwise Vorbis residue type is invalid."); + + output.WriteBits(residueType, VorbisResidueTypeBitWidth); + TranscribeResidueBody(input, output, codebookCount); + } + + public static int TranscribeMappingConfigurationsWwiseToVorbis(BitChunk input, BitWriter output, int channelCount, int floorCount, int residueCount) + { + var mappingCountField = input.ReadBits(ConfigurationCountFieldBitWidth); + output.WriteBits(mappingCountField, ConfigurationCountFieldBitWidth); + + var mappingCount = (int)mappingCountField + 1; + for (var mappingIndex = 0; mappingIndex < mappingCount; mappingIndex++) + TranscribeMappingConfigurationWwiseToVorbis(input, output, channelCount, floorCount, residueCount); + + return mappingCount; + } + + public static void TranscribeMappingConfigurationWwiseToVorbis(BitChunk input, BitWriter output, int channelCount, int floorCount, int residueCount) + { + output.WriteBits(VorbisMappingType0, VorbisMappingTypeBitWidth); + TranscribeMappingBody(input, output, channelCount, floorCount, residueCount); + } + + public static void TranscribeFloor1Body(BitChunk input, BitWriter output, int codebookCount = -1) + { + var floorPartitionCountBitWidth = 5; + var floorClassIndexBitWidth = 4; + var floorClassDimensionsBitWidth = 3; + var floorSubclassCountBitWidth = 2; + var floorMasterbookIndexBitWidth = 8; + var floorSubclassBookPlusOneBitWidth = 8; + var floorMultiplierBitWidth = 2; + var floorRangeBitsBitWidth = 4; + + var partitionCount = (int)input.ReadBits(floorPartitionCountBitWidth); + output.WriteBits((uint)partitionCount, floorPartitionCountBitWidth); + + var classIndexPerPartition = new int[partitionCount]; + var highestClassIndex = 0; + for (var partitionIndex = 0; partitionIndex < partitionCount; partitionIndex++) + { + var classIndex = (int)input.ReadBits(floorClassIndexBitWidth); + output.WriteBits((uint)classIndex, floorClassIndexBitWidth); + classIndexPerPartition[partitionIndex] = classIndex; + highestClassIndex = Math.Max(highestClassIndex, classIndex); + } + + var dimensionsPerClass = new int[highestClassIndex + 1]; + for (var classIndex = 0; classIndex <= highestClassIndex; classIndex++) + { + var classDimensionsField = (int)input.ReadBits(floorClassDimensionsBitWidth); + output.WriteBits((uint)classDimensionsField, floorClassDimensionsBitWidth); + dimensionsPerClass[classIndex] = classDimensionsField + 1; + var subclassCount = (int)input.ReadBits(floorSubclassCountBitWidth); + output.WriteBits((uint)subclassCount, floorSubclassCountBitWidth); + + if (subclassCount != 0) + { + var masterbookIndex = input.ReadBits(floorMasterbookIndexBitWidth); + if (codebookCount >= 0 && masterbookIndex >= (uint)codebookCount) + throw new InvalidDataException("Wwise Vorbis floor master book index is out of range."); + output.WriteBits(masterbookIndex, floorMasterbookIndexBitWidth); + } + + for (var subclassIndex = 0; subclassIndex < (1 << subclassCount); subclassIndex++) + { + var subclassBookPlusOne = (int)input.ReadBits(floorSubclassBookPlusOneBitWidth); + output.WriteBits((uint)subclassBookPlusOne, floorSubclassBookPlusOneBitWidth); + if (codebookCount >= 0 && subclassBookPlusOne - 1 >= 0 && subclassBookPlusOne - 1 >= codebookCount) + throw new InvalidDataException("Wwise Vorbis floor subclass book index is out of range."); + } + } + + var multiplier = input.ReadBits(floorMultiplierBitWidth); + output.WriteBits(multiplier, floorMultiplierBitWidth); + + var rangeBits = (int)input.ReadBits(floorRangeBitsBitWidth); + output.WriteBits((uint)rangeBits, floorRangeBitsBitWidth); + + for (var partitionIndex = 0; partitionIndex < partitionCount; partitionIndex++) + { + var classIndex = classIndexPerPartition[partitionIndex]; + for (var dimensionIndex = 0; dimensionIndex < dimensionsPerClass[classIndex]; dimensionIndex++) + output.WriteBits(input.ReadBits(rangeBits), rangeBits); + } + } + + public static void TranscribeResidueBody(BitChunk input, BitWriter output, int codebookCount = -1) + { + var residueVectorFieldBitWidth = 24; + var residueClassificationCountBitWidth = 6; + var residueClassbookIndexBitWidth = 8; + var residueCascadeLowBitsWidth = 3; + var residueCascadeHasHighBitsWidth = 1; + var residueCascadeHighBitsWidth = 5; + var residueBookIndexBitWidth = 8; + var cascadeVectorBitCount = 8; + + output.WriteBits(input.ReadBits(residueVectorFieldBitWidth), residueVectorFieldBitWidth); + output.WriteBits(input.ReadBits(residueVectorFieldBitWidth), residueVectorFieldBitWidth); + output.WriteBits(input.ReadBits(residueVectorFieldBitWidth), residueVectorFieldBitWidth); + + var classificationCountField = input.ReadBits(residueClassificationCountBitWidth); + output.WriteBits(classificationCountField, residueClassificationCountBitWidth); + var classificationCount = (int)classificationCountField + 1; + + var classbookIndex = input.ReadBits(residueClassbookIndexBitWidth); + if (codebookCount >= 0 && classbookIndex >= (uint)codebookCount) + throw new InvalidDataException("Wwise Vorbis residue classbook index is out of range."); + + output.WriteBits(classbookIndex, residueClassbookIndexBitWidth); + + var cascadeVectors = new int[classificationCount]; + for (var classIndex = 0; classIndex < classificationCount; classIndex++) + { + var lowBits = (int)input.ReadBits(residueCascadeLowBitsWidth); + output.WriteBits((uint)lowBits, residueCascadeLowBitsWidth); + + var hasHighBits = input.ReadBits(residueCascadeHasHighBitsWidth); + output.WriteBits(hasHighBits, residueCascadeHasHighBitsWidth); + + var highBits = 0; + if (hasHighBits != 0) + { + highBits = (int)input.ReadBits(residueCascadeHighBitsWidth); + output.WriteBits((uint)highBits, residueCascadeHighBitsWidth); + } + + cascadeVectors[classIndex] = (highBits << 3) | lowBits; + } + + for (var classIndex = 0; classIndex < classificationCount; classIndex++) + { + for (var bookBitPosition = 0; bookBitPosition < cascadeVectorBitCount; bookBitPosition++) + { + if (!BitHelper.IsBitSet(cascadeVectors[classIndex], bookBitPosition)) + continue; + + var bookIndex = input.ReadBits(residueBookIndexBitWidth); + if (codebookCount >= 0 && bookIndex >= (uint)codebookCount) + throw new InvalidDataException("Wwise Vorbis residue book index is out of range."); + + output.WriteBits(bookIndex, residueBookIndexBitWidth); + } + } + } + + public static void TranscribeMappingBody(BitChunk input, BitWriter output, int channelCount, int floorCount = -1, int residueCount = -1) + { + var mappingHasMultipleSubmapsBitWidth = 1; + var mappingSubmapCountFieldBitWidth = 4; + var mappingHasCouplingStepsBitWidth = 1; + var mappingCouplingStepCountFieldBitWidth = 8; + var mappingReservedFieldBitWidth = 2; + var mappingChannelSubmapIndexBitWidth = 4; + var mappingSubmapFieldBitWidth = 8; + + var hasMultipleSubmaps = input.ReadBits(mappingHasMultipleSubmapsBitWidth); + output.WriteBits(hasMultipleSubmaps, mappingHasMultipleSubmapsBitWidth); + + var submapCount = 1; + if (hasMultipleSubmaps != 0) + { + var submapCountField = (int)input.ReadBits(mappingSubmapCountFieldBitWidth); + output.WriteBits((uint)submapCountField, mappingSubmapCountFieldBitWidth); + submapCount = submapCountField + 1; + } + + var hasCouplingSteps = input.ReadBits(mappingHasCouplingStepsBitWidth); + output.WriteBits(hasCouplingSteps, mappingHasCouplingStepsBitWidth); + + if (hasCouplingSteps != 0) + { + var couplingStepCountField = (int)input.ReadBits(mappingCouplingStepCountFieldBitWidth); + output.WriteBits((uint)couplingStepCountField, mappingCouplingStepCountFieldBitWidth); + + var couplingStepCount = couplingStepCountField + 1; + var channelIndexBits = IntegerLog(channelCount - 1); + for (var stepIndex = 0; stepIndex < couplingStepCount; stepIndex++) + { + var magnitudeChannelIndex = (int)input.ReadBits(channelIndexBits); + var angleChannelIndex = (int)input.ReadBits(channelIndexBits); + if (floorCount >= 0 && (magnitudeChannelIndex >= channelCount || angleChannelIndex >= channelCount || magnitudeChannelIndex == angleChannelIndex)) + throw new InvalidDataException("Wwise Vorbis channel coupling step references invalid channel indices."); + + output.WriteBits((uint)magnitudeChannelIndex, channelIndexBits); + output.WriteBits((uint)angleChannelIndex, channelIndexBits); + } + } + + var reservedField = input.ReadBits(mappingReservedFieldBitWidth); + if (floorCount >= 0 && reservedField != 0) + throw new InvalidDataException("Wwise Vorbis mapping reserved field must be zero."); + + output.WriteBits(reservedField, mappingReservedFieldBitWidth); + + if (submapCount > 1) + { + for (var channelIndex = 0; channelIndex < channelCount; channelIndex++) + { + var channelSubmapIndex = input.ReadBits(mappingChannelSubmapIndexBitWidth); + if (floorCount >= 0 && channelSubmapIndex >= (uint)submapCount) + throw new InvalidDataException("Wwise Vorbis channel submap index is out of range."); + + output.WriteBits(channelSubmapIndex, mappingChannelSubmapIndexBitWidth); + } + } + + for (var submapIndex = 0; submapIndex < submapCount; submapIndex++) + { + var unusedSubmapField = input.ReadBits(mappingSubmapFieldBitWidth); + output.WriteBits(unusedSubmapField, mappingSubmapFieldBitWidth); + var floorIndex = input.ReadBits(mappingSubmapFieldBitWidth); + if (floorCount >= 0 && floorIndex >= (uint)floorCount) + throw new InvalidDataException("Wwise Vorbis submap floor index is out of range."); + + output.WriteBits(floorIndex, mappingSubmapFieldBitWidth); + var residueIndex = input.ReadBits(mappingSubmapFieldBitWidth); + if (residueCount >= 0 && residueIndex >= (uint)residueCount) + throw new InvalidDataException("Wwise Vorbis submap residue index is out of range."); + + output.WriteBits(residueIndex, mappingSubmapFieldBitWidth); + } + } + } +} diff --git a/Shared/GameFiles/Wwise/WwiseHash.cs b/Shared/GameFiles/Wwise/WwiseHash.cs new file mode 100644 index 000000000..9d0c6bf7f --- /dev/null +++ b/Shared/GameFiles/Wwise/WwiseHash.cs @@ -0,0 +1,25 @@ +using System.Text; + +namespace Shared.GameFormats.Wwise +{ + public static class WwiseHash + { + private const uint FnvOffsetBasis = 2166136261u; + private const uint FnvPrime = 16777619u; + + public static uint Compute(string key) + { + var normalisedKey = key.ToLower().Trim(); + var bytes = Encoding.UTF8.GetBytes(normalisedKey); + return Compute(bytes); + } + + public static uint Compute(byte[] bytes) + { + var hash = FnvOffsetBasis; + foreach (var b in bytes) + hash = (hash * FnvPrime) ^ b; + return hash; + } + } +} From 6dac3f26085a11148ae41b87ccaa55646f911698 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Tue, 12 May 2026 22:06:24 +0100 Subject: [PATCH 4/5] Implemented native WAV and WEM across audio tools --- .../Core/AudioEditorIntegrityService.cs | 2 +- .../Presentation/AudioEditorView.xaml | 2 +- .../Presentation/AudioEditorView.xaml.cs | 2 +- .../Presentation/AudioEditorViewModel.cs | 2 +- .../AudioFilesExplorerViewModel.cs | 2 +- .../AudioProjectEditorViewModel.cs | 8 +- .../Settings/SettingsViewModel.cs | 2 +- .../WaveformVisualiserHelpers.cs | 7 -- .../AudioExplorer/AudioExplorerView.xaml | 36 +++--- .../AudioExplorer/AudioExplorerView.xaml.cs | 5 - .../AudioExplorer/AudioExplorerViewModel.cs | 87 +++++++------- .../AudioProjectConverterViewModel.cs | 33 +++--- Editors/Audio/DependencyInjectionContainer.cs | 4 +- Editors/Audio/Editors.Audio.csproj | 4 +- .../Compiler/AudioProjectCompilerService.cs | 11 +- .../AudioProject/Compiler/IdGenerator.cs | 4 +- .../AudioProject/Models/DialogueEvent.cs | 2 +- .../Shared/AudioProject/Models/SoundBank.cs | 2 +- .../Audio/Shared/AudioProject/Models/State.cs | 2 +- .../Shared/AudioProject/Models/StateGroup.cs | 2 +- .../Warhammer3/Wh3ActionEventInformation.cs | 12 +- .../Audio/Shared/Storage/AudioRepository.cs | 41 ++++++- Editors/Audio/Shared/Storage/DatLoader.cs | 2 +- .../Hirc/V136/NodeBaseParamsGenerator_V136.cs | 6 +- .../Wwise/Generators/WemGeneratorService.cs | 50 +++----- Editors/Audio/Shared/Wwise/SoundEngine.cs | 29 ++++- .../Events}/CacheWaveformRequestedEvent.cs | 2 +- .../Events}/DecacheWaveformRequestedEvent.cs | 2 +- .../Events}/PlayAudioRequestedEvent.cs | 2 +- .../Presentation}/WaveformRendererService.cs | 74 +++++++++--- .../Presentation}/WaveformVisualisation.cs | 2 +- .../WaveformVisualisationCacheService.cs | 2 +- .../Presentation}/WaveformVisualiserView.xaml | 9 +- .../WaveformVisualiserView.xaml.cs | 2 +- .../WavformVisualiserViewModel.cs | 110 +++++++++++++++++- .../GameWorld.Core/GameWorld.Core.csproj | 1 + .../Shared.Core/Misc/DirectoryHelper.cs | 1 + Shared/SharedUI/Shared.Ui/Shared.Ui.csproj | 1 - 38 files changed, 383 insertions(+), 184 deletions(-) delete mode 100644 Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs rename Editors/Audio/{AudioEditor/Events/WaveformVisualiser => WaveformVisualiser/Events}/CacheWaveformRequestedEvent.cs (64%) rename Editors/Audio/{AudioEditor/Events/WaveformVisualiser => WaveformVisualiser/Events}/DecacheWaveformRequestedEvent.cs (64%) rename Editors/Audio/{AudioEditor/Events/WaveformVisualiser => WaveformVisualiser/Events}/PlayAudioRequestedEvent.cs (64%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WaveformRendererService.cs (52%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WaveformVisualisation.cs (91%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WaveformVisualisationCacheService.cs (98%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WaveformVisualiserView.xaml (88%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WaveformVisualiserView.xaml.cs (89%) rename Editors/Audio/{AudioEditor/Presentation/WaveformVisualiser => WaveformVisualiser/Presentation}/WavformVisualiserViewModel.cs (81%) diff --git a/Editors/Audio/AudioEditor/Core/AudioEditorIntegrityService.cs b/Editors/Audio/AudioEditor/Core/AudioEditorIntegrityService.cs index 20f9b4e78..714a34767 100644 --- a/Editors/Audio/AudioEditor/Core/AudioEditorIntegrityService.cs +++ b/Editors/Audio/AudioEditor/Core/AudioEditorIntegrityService.cs @@ -7,7 +7,7 @@ using Editors.Audio.Shared.AudioProject.Models; using Editors.Audio.Shared.GameInformation.Warhammer3; using Editors.Audio.Shared.Storage; -using Editors.Audio.Shared.Wwise; +using Shared.GameFormats.Wwise; using Shared.Core.PackFiles; using Shared.GameFormats.Wwise.Hirc; diff --git a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml index ec23a43f7..8e22ee4c6 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml +++ b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml @@ -12,7 +12,7 @@ xmlns:AudioProjectEditor="clr-namespace:Editors.Audio.AudioEditor.Presentation.AudioProjectEditor" xmlns:Settings="clr-namespace:Editors.Audio.AudioEditor.Presentation.Settings" xmlns:AudioProjectViewer="clr-namespace:Editors.Audio.AudioEditor.Presentation.AudioProjectViewer" - xmlns:WaveformVisualiser="clr-namespace:Editors.Audio.AudioEditor.Presentation.WaveformVisualiser" + xmlns:WaveformVisualiser="clr-namespace:Editors.Audio.WaveformVisualiser.Presentation" xmlns:loc="clr-namespace:Shared.Ui.Common;assembly=Shared.Ui" d:DataContext="{d:DesignInstance Type=Local:AudioEditorViewModel}" mc:Ignorable="d" diff --git a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml.cs b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml.cs index b7c4c6fbb..cf6ceceb3 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioEditorView.xaml.cs @@ -8,7 +8,7 @@ using Editors.Audio.AudioEditor.Presentation.AudioProjectExplorer; using Editors.Audio.AudioEditor.Presentation.AudioProjectViewer; using Editors.Audio.AudioEditor.Presentation.Settings; -using Editors.Audio.AudioEditor.Presentation.WaveformVisualiser; +using Editors.Audio.WaveformVisualiser.Presentation; namespace Editors.Audio.AudioEditor.Presentation { diff --git a/Editors/Audio/AudioEditor/Presentation/AudioEditorViewModel.cs b/Editors/Audio/AudioEditor/Presentation/AudioEditorViewModel.cs index 6dcdeecd2..74a2256af 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioEditorViewModel.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioEditorViewModel.cs @@ -10,7 +10,7 @@ using Editors.Audio.AudioEditor.Presentation.AudioProjectExplorer; using Editors.Audio.AudioEditor.Presentation.AudioProjectViewer; using Editors.Audio.AudioEditor.Presentation.Settings; -using Editors.Audio.AudioEditor.Presentation.WaveformVisualiser; +using Editors.Audio.WaveformVisualiser.Presentation; using Editors.Audio.Shared.AudioProject.Compiler; using Shared.Core.Events; using Shared.Core.ToolCreation; diff --git a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs index 3981ee314..e3ea52abc 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioFilesExplorer/AudioFilesExplorerViewModel.cs @@ -12,7 +12,7 @@ using Editors.Audio.AudioEditor.Core; using Editors.Audio.AudioEditor.Events.AudioFilesExplorer; using Editors.Audio.AudioEditor.Events.AudioProjectExplorer; -using Editors.Audio.AudioEditor.Events.WaveformVisualiser; +using Editors.Audio.WaveformVisualiser.Events; using Editors.Audio.AudioEditor.Presentation.Shared.Models; using Shared.Core.Events; using Shared.Core.Events.Global; diff --git a/Editors/Audio/AudioEditor/Presentation/AudioProjectEditor/AudioProjectEditorViewModel.cs b/Editors/Audio/AudioEditor/Presentation/AudioProjectEditor/AudioProjectEditorViewModel.cs index 1f12ea869..45b2b4643 100644 --- a/Editors/Audio/AudioEditor/Presentation/AudioProjectEditor/AudioProjectEditorViewModel.cs +++ b/Editors/Audio/AudioEditor/Presentation/AudioProjectEditor/AudioProjectEditorViewModel.cs @@ -21,6 +21,7 @@ using Editors.Audio.AudioEditor.Presentation.Shared.Models; using Editors.Audio.AudioEditor.Presentation.Shared.Table; using Editors.Audio.Shared.AudioProject.Models; +using Editors.Audio.Shared.GameInformation.Warhammer3; using Editors.Audio.Shared.Storage; using Serilog; using Shared.Core.ErrorHandling; @@ -223,13 +224,8 @@ private void SetEventNameFromAudioFile(List audioFiles, bool addToExi private void SetMovieFilePath(string movieFilePath) { - var relativePath = Path.GetRelativePath("movies", movieFilePath); - var withoutExtension = Path.ChangeExtension(relativePath, null); - var slashesToUnderscores = withoutExtension.Replace("\\", "_"); - var eventName = $"Play_Movie_{slashesToUnderscores}"; - var row = Table.Rows[0]; - row[TableInformation.ActionEventColumnName] = eventName; + row[TableInformation.ActionEventColumnName] = Wh3ActionEventInformation.GetMovieActionEventName(movieFilePath); } public void OnEditorAddRowButtonEnablementUpdateRequested(EditorAddRowButtonEnablementUpdateRequestedEvent e) => SetAddRowButtonEnablement(); diff --git a/Editors/Audio/AudioEditor/Presentation/Settings/SettingsViewModel.cs b/Editors/Audio/AudioEditor/Presentation/Settings/SettingsViewModel.cs index ef6558f78..6be0814fa 100644 --- a/Editors/Audio/AudioEditor/Presentation/Settings/SettingsViewModel.cs +++ b/Editors/Audio/AudioEditor/Presentation/Settings/SettingsViewModel.cs @@ -11,7 +11,7 @@ using Editors.Audio.AudioEditor.Events.AudioProjectExplorer; using Editors.Audio.AudioEditor.Events.AudioProjectViewer.Table; using Editors.Audio.AudioEditor.Events.Settings.Shortcuts; -using Editors.Audio.AudioEditor.Events.WaveformVisualiser; +using Editors.Audio.WaveformVisualiser.Events; using Editors.Audio.AudioEditor.Presentation.Shared.Models; using Editors.Audio.AudioEditor.Presentation.Shared.Table; using Editors.Audio.Shared.AudioProject.Compiler; diff --git a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs b/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs deleted file mode 100644 index f4fc28050..000000000 --- a/Editors/Audio/AudioEditor/Presentation/WaveformVisualiser/WaveformVisualiserHelpers.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Editors.Audio.AudioEditor.Presentation.WaveformVisualiser -{ - public class WaveformVisualiserHelpers - { - - } -} diff --git a/Editors/Audio/AudioExplorer/AudioExplorerView.xaml b/Editors/Audio/AudioExplorer/AudioExplorerView.xaml index 991069be3..6f1e4b44c 100644 --- a/Editors/Audio/AudioExplorer/AudioExplorerView.xaml +++ b/Editors/Audio/AudioExplorer/AudioExplorerView.xaml @@ -9,6 +9,7 @@ xmlns:Behaviors="clr-namespace:Shared.Ui.Common.Behaviors;assembly=Shared.Ui" xmlns:ValueConverters="clr-namespace:Editors.Audio.Shared.UI.ValueConverters" xmlns:AudioExplorer="clr-namespace:Editors.Audio.AudioExplorer" + xmlns:WaveformVisualiser="clr-namespace:Editors.Audio.WaveformVisualiser.Presentation" xmlns:loc="clr-namespace:Shared.Ui.Common;assembly=Shared.Ui" d:DataContext="{d:DesignInstance Type=AudioExplorer:AudioExplorerViewModel}" mc:Ignorable="d" @@ -56,10 +57,12 @@ Height="Auto"/> + + Height="161"/> -