From a6f02bfa8446b9e5e46a07678dd7e7565d0c4338 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Sat, 16 May 2026 12:49:07 +0100 Subject: [PATCH 1/2] Properly modelled OGG and PCM and improved organisation --- .../AudioProjectConverterViewModel.cs | 13 +- Editors/Audio/Editors.Audio.csproj | 4 + Editors/Audio/Shared/Wwise/SoundEngine.cs | 18 +- .../Presentation/WaveformRendererService.cs | 8 +- Shared/GameFiles/Audio/Codecs/PcmAudio.cs | 14 -- .../Audio/Codecs/Vorbis/VorbisAudio.cs | 26 +++ .../Codecs/{ => Vorbis}/VorbisAudioPacket.cs | 2 +- .../Codecs/Vorbis/VorbisCommentPacket.cs | 98 ++++++++ .../Vorbis/VorbisIdentificationPacket.cs | 82 +++++++ .../Audio/Codecs/Vorbis/VorbisSetupPacket.cs | 47 ++++ Shared/GameFiles/Audio/Codecs/VorbisAudio.cs | 133 ----------- .../GameFiles/Audio/Codecs/VorbisHeaders.cs | 9 - .../GameFiles/Audio/Containers/Ogg/OggFile.cs | 220 ++++++++++++++++++ .../Ogg/OggPacket.cs} | 4 +- .../Audio/Containers/Ogg/OggPageHeader.cs | 69 ++++++ .../Audio/{ => Containers}/Wav/DataChunk.cs | 2 +- .../Audio/{ => Containers}/Wav/FmtChunk.cs | 2 +- .../Audio/{ => Containers}/Wav/RiffChunk.cs | 2 +- .../{ => Containers}/Wav/WavChunkFactory.cs | 2 +- .../{ => Containers}/Wav/WavChunkHeader.cs | 2 +- .../Audio/{ => Containers}/Wav/WavFile.cs | 9 +- .../{ => Containers}/Wav/WavFileHeader.cs | 2 +- .../GameFiles/Audio/Formats/Pcm/PcmAudio.cs | 57 +++++ Shared/GameFiles/Audio/Ogg/OggSerialiser.cs | 112 --------- .../Wem/V132/Decoding/VorbisCommentHeader.cs | 25 +- .../Decoding/VorbisIdentificationHeader.cs | 31 +-- .../Wem/V132/Decoding/WemVorbisDecoder.cs | 17 +- .../Wem/V132/Encoding/WemVorbisEncoder.cs | 15 +- Shared/GameFiles/Wwise/Wem/V132/WemFile.cs | 11 +- 29 files changed, 672 insertions(+), 364 deletions(-) delete mode 100644 Shared/GameFiles/Audio/Codecs/PcmAudio.cs create mode 100644 Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudio.cs rename Shared/GameFiles/Audio/Codecs/{ => Vorbis}/VorbisAudioPacket.cs (74%) create mode 100644 Shared/GameFiles/Audio/Codecs/Vorbis/VorbisCommentPacket.cs create mode 100644 Shared/GameFiles/Audio/Codecs/Vorbis/VorbisIdentificationPacket.cs create mode 100644 Shared/GameFiles/Audio/Codecs/Vorbis/VorbisSetupPacket.cs delete mode 100644 Shared/GameFiles/Audio/Codecs/VorbisAudio.cs delete mode 100644 Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs create mode 100644 Shared/GameFiles/Audio/Containers/Ogg/OggFile.cs rename Shared/GameFiles/Audio/{Ogg/OggAudioPacket.cs => Containers/Ogg/OggPacket.cs} (73%) create mode 100644 Shared/GameFiles/Audio/Containers/Ogg/OggPageHeader.cs rename Shared/GameFiles/Audio/{ => Containers}/Wav/DataChunk.cs (90%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/FmtChunk.cs (97%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/RiffChunk.cs (93%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/WavChunkFactory.cs (86%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/WavChunkHeader.cs (96%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/WavFile.cs (94%) rename Shared/GameFiles/Audio/{ => Containers}/Wav/WavFileHeader.cs (97%) create mode 100644 Shared/GameFiles/Audio/Formats/Pcm/PcmAudio.cs delete mode 100644 Shared/GameFiles/Audio/Ogg/OggSerialiser.cs diff --git a/Editors/Audio/AudioProjectConverter/AudioProjectConverterViewModel.cs b/Editors/Audio/AudioProjectConverter/AudioProjectConverterViewModel.cs index e9ec80f7f..86f908940 100644 --- a/Editors/Audio/AudioProjectConverter/AudioProjectConverterViewModel.cs +++ b/Editors/Audio/AudioProjectConverter/AudioProjectConverterViewModel.cs @@ -17,13 +17,11 @@ using Shared.Core.PackFiles.Models; using Shared.Core.Services; using Shared.Core.Settings; +using Shared.GameFormats.Audio.Formats.Pcm; using Shared.GameFormats.Wwise; using Shared.GameFormats.Wwise.Enums; using Shared.GameFormats.Wwise.Hirc; -using Shared.GameFormats.Wwise.Wem.V132; -using Shared.GameFormats.Wwise.Wem.V132.Decoding; -using Shared.GameFormats.Wwise.Wem.V132.Encoding; -using Wav = Shared.GameFormats.Audio.Wav.WavFile; +using Wav = Shared.GameFormats.Audio.Containers.Wav.WavFile; namespace Editors.Audio.AudioProjectConverter { @@ -504,9 +502,6 @@ private void ProcessWavFiles( string voActor, HashSet usedSourceIds) { - var codebookLibrary = new WwiseCodebookLibrary(); - var decoder = new WemVorbisDecoder(codebookLibrary); - var voActorSegment = voActor.Substring(voActor.IndexOf("vo_actor_") + "vo_actor_".Length).ToLower(); foreach (var wavFile in statePathWavs) @@ -540,8 +535,8 @@ private void ProcessWavFiles( var wavPackOutputPath = $"{OutputDirectoryPath}\\vo\\{voActorSegment}\\{wavFile.FileName}".ToLower(); var wemFileBytes = File.ReadAllBytes(wemFilePath); - var vorbisAudio = decoder.Decode(WemFile.CreateFromBytes(wemFileBytes)); - var wavFileBytes = new Wav { Audio = vorbisAudio.ToPcm() }.WriteData(); + var wav = new Wav { Audio = PcmAudio.CreateFromWemBytes(wemFileBytes) }; + var wavFileBytes = wav.WriteData(); _fileSaveService.Save(wavPackOutputPath, wavFileBytes, false); } } diff --git a/Editors/Audio/Editors.Audio.csproj b/Editors/Audio/Editors.Audio.csproj index 70fa20712..b3e40bebf 100644 --- a/Editors/Audio/Editors.Audio.csproj +++ b/Editors/Audio/Editors.Audio.csproj @@ -7,6 +7,10 @@ + + + + diff --git a/Editors/Audio/Shared/Wwise/SoundEngine.cs b/Editors/Audio/Shared/Wwise/SoundEngine.cs index 38afcaa56..89f3e7d87 100644 --- a/Editors/Audio/Shared/Wwise/SoundEngine.cs +++ b/Editors/Audio/Shared/Wwise/SoundEngine.cs @@ -5,9 +5,7 @@ using NAudio.Vorbis; using NAudio.Wave; using Shared.Core.PackFiles; -using Shared.GameFormats.Wwise.Wem.V132; -using Shared.GameFormats.Wwise.Wem.V132.Decoding; -using Shared.GameFormats.Wwise.Wem.V132.Encoding; +using Shared.GameFormats.Audio.Containers.Ogg; namespace Editors.Audio.Shared.Wwise { @@ -39,15 +37,7 @@ public class SoundEngine(IPackFileService packFileService) : ISoundEngine public TimeSpan ReaderTimeAtLastPlayOrResume { get; private set; } = TimeSpan.Zero; public long DeviceBytesAtLastPlayOrResume { get; private set; } = 0; - public PlaybackState PlaybackState - { - get - { - if (_wavePlayer == null) - return PlaybackState.Stopped; - return _wavePlayer.PlaybackState; - } - } + public PlaybackState PlaybackState => _wavePlayer?.PlaybackState ?? PlaybackState.Stopped; public event EventHandler PlaybackStopped; @@ -80,9 +70,7 @@ public void LoadFromWemBytes(byte[] wemBytes) { DisposeReaderOnly(); - var codebookLibrary = new WwiseCodebookLibrary(); - var decoder = new WemVorbisDecoder(codebookLibrary); - var oggBytes = decoder.Decode(WemFile.CreateFromBytes(wemBytes)).ToOgg(); + var oggBytes = OggFile.CreateFromWemBytes(wemBytes).WriteData(); _memoryStream = new MemoryStream(oggBytes, writable: false); _waveFileReader = new VorbisWaveReader(_memoryStream); diff --git a/Editors/Audio/WaveformVisualiser/Presentation/WaveformRendererService.cs b/Editors/Audio/WaveformVisualiser/Presentation/WaveformRendererService.cs index 2c12e7ba9..61cdb7fa2 100644 --- a/Editors/Audio/WaveformVisualiser/Presentation/WaveformRendererService.cs +++ b/Editors/Audio/WaveformVisualiser/Presentation/WaveformRendererService.cs @@ -8,9 +8,7 @@ using NAudio.Wave; using NAudio.WaveFormRenderer; using Shared.Core.PackFiles; -using Shared.GameFormats.Wwise.Wem.V132; -using Shared.GameFormats.Wwise.Wem.V132.Decoding; -using Shared.GameFormats.Wwise.Wem.V132.Encoding; +using Shared.GameFormats.Audio.Containers.Ogg; using Color = System.Drawing.Color; using DrawingImage = System.Drawing.Image; @@ -55,9 +53,7 @@ public async Task RenderFromWemBytesAsync(byte[] wemBytes, return await Task.Run(() => { - var codebookLibrary = new WwiseCodebookLibrary(); - var decoder = new WemVorbisDecoder(codebookLibrary); - var oggBytes = decoder.Decode(WemFile.CreateFromBytes(wemBytes)).ToOgg(); + var oggBytes = OggFile.CreateFromWemBytes(wemBytes).WriteData(); return RenderWaveformFromOggBytes(oggBytes, baseSettings, overlaySettings); }, cancellationToken).ConfigureAwait(false); } diff --git a/Shared/GameFiles/Audio/Codecs/PcmAudio.cs b/Shared/GameFiles/Audio/Codecs/PcmAudio.cs deleted file mode 100644 index ef0aaf900..000000000 --- a/Shared/GameFiles/Audio/Codecs/PcmAudio.cs +++ /dev/null @@ -1,14 +0,0 @@ -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/Codecs/Vorbis/VorbisAudio.cs b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudio.cs new file mode 100644 index 000000000..f784db52a --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudio.cs @@ -0,0 +1,26 @@ +using Shared.GameFormats.Wwise.Wem.V132; +using Shared.GameFormats.Wwise.Wem.V132.Decoding; +using Shared.GameFormats.Wwise.Wem.V132.Encoding; + +namespace Shared.GameFormats.Audio.Codecs.Vorbis +{ + public class VorbisAudio + { + public byte Channels { get; set; } + public byte[] VorbisCodecPrivateData { get; set; } = []; + public VorbisIdentificationPacket IdentificationHeader { get; set; } = new(); + public VorbisCommentPacket CommentHeader { get; set; } = new(); + public VorbisSetupPacket SetupHeader { get; set; } = new(); + public List Packets { get; set; } = []; + public int SampleCount { get; set; } + public uint SampleRate { get; set; } + + public static VorbisAudio CreateFromWemBytes(byte[] wemBytes) + { + var wemFile = WemFile.CreateFromWemBytes(wemBytes); + var codebookLibrary = new WwiseCodebookLibrary(); + var decoder = new WemVorbisDecoder(codebookLibrary); + return decoder.Decode(wemFile); + } + } +} diff --git a/Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudioPacket.cs similarity index 74% rename from Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs rename to Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudioPacket.cs index 06e79bedd..be6beecf8 100644 --- a/Shared/GameFiles/Audio/Codecs/VorbisAudioPacket.cs +++ b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisAudioPacket.cs @@ -1,4 +1,4 @@ -namespace Shared.GameFormats.Audio.Codecs +namespace Shared.GameFormats.Audio.Codecs.Vorbis { public class VorbisAudioPacket { diff --git a/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisCommentPacket.cs b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisCommentPacket.cs new file mode 100644 index 000000000..0ba629c4f --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisCommentPacket.cs @@ -0,0 +1,98 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Codecs.Vorbis +{ + public class VorbisCommentPacket + { + private const byte ExpectedPacketType = 0x03; + private const string ExpectedHeaderTag = "vorbis"; + + public byte PacketType { get; set; } = ExpectedPacketType; + public string HeaderTag { get; set; } = ExpectedHeaderTag; + public string VendorString { get; set; } = string.Empty; + public List UserComments { get; set; } = []; + public uint FramingBit { get; set; } = 1u; + + public bool HasData => !string.IsNullOrEmpty(HeaderTag); + + public static VorbisCommentPacket ReadData(byte[] packetData) + { + var reader = new ByteChunk(packetData); + var packet = new VorbisCommentPacket + { + PacketType = reader.ReadByte(), + HeaderTag = Encoding.ASCII.GetString(reader.ReadBytes(6)), + }; + + if (packet.PacketType != ExpectedPacketType) + throw new InvalidDataException($"Vorbis comment header packet type must be 0x{ExpectedPacketType:X2}."); + if (packet.HeaderTag != ExpectedHeaderTag) + throw new InvalidDataException("Vorbis comment header is missing the expected tag."); + + var vendorStringLengthU32 = reader.ReadUInt32(); + if (vendorStringLengthU32 > int.MaxValue) + throw new InvalidDataException("Vorbis comment vendor string length is too large."); + + var vendorStringLength = (int)vendorStringLengthU32; + if (vendorStringLength > reader.BytesLeft) + throw new InvalidDataException("Vorbis comment vendor string exceeds packet length."); + + packet.VendorString = Encoding.ASCII.GetString(reader.ReadBytes(vendorStringLength)); + + var commentCountU32 = reader.ReadUInt32(); + if (commentCountU32 > int.MaxValue) + throw new InvalidDataException("Vorbis comment count is too large."); + + var commentCount = (int)commentCountU32; + packet.UserComments = []; + for (var commentIndex = 0; commentIndex < commentCount; commentIndex++) + { + var commentLengthU32 = reader.ReadUInt32(); + if (commentLengthU32 > int.MaxValue) + throw new InvalidDataException("Vorbis comment entry length is too large."); + + var commentLength = (int)commentLengthU32; + if (commentLength > reader.BytesLeft) + throw new InvalidDataException("Vorbis comment entry exceeds packet length."); + + var comment = Encoding.ASCII.GetString(reader.ReadBytes(commentLength)); + packet.UserComments.Add(comment); + } + + var framingByte = reader.ReadByte(); + packet.FramingBit = (uint)BitHelper.ExtractBits(framingByte, 0, 1); + if (packet.FramingBit != 1u) + throw new InvalidDataException("Vorbis comment header framing flag must be set."); + + if (reader.BytesLeft != 0) + throw new InvalidDataException("Vorbis comment packet contains trailing data."); + + return packet; + } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.Byte.EncodeValue(PacketType, out _)); + stream.Write(Encoding.ASCII.GetBytes(HeaderTag)); + + var vendorBytes = Encoding.ASCII.GetBytes(VendorString); + stream.Write(ByteParsers.UInt32.EncodeValue((uint)vendorBytes.Length, out _)); + stream.Write(vendorBytes); + + stream.Write(ByteParsers.UInt32.EncodeValue((uint)UserComments.Count, out _)); + foreach (var comment in UserComments) + { + var commentBytes = Encoding.ASCII.GetBytes(comment); + stream.Write(ByteParsers.UInt32.EncodeValue((uint)commentBytes.Length, out _)); + stream.Write(commentBytes); + } + + var framingByte = (byte)BitHelper.ExtractBits(FramingBit, 0, 1); + stream.Write(ByteParsers.Byte.EncodeValue(framingByte, out _)); + + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisIdentificationPacket.cs b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisIdentificationPacket.cs new file mode 100644 index 000000000..3acf81c8f --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisIdentificationPacket.cs @@ -0,0 +1,82 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Codecs.Vorbis +{ + public class VorbisIdentificationPacket + { + private const byte ExpectedPacketType = 0x01; + private const string ExpectedHeaderTag = "vorbis"; + + public byte PacketType { get; set; } = ExpectedPacketType; + public string HeaderTag { get; set; } = ExpectedHeaderTag; + public uint Version { get; set; } + public byte Channels { get; set; } + public uint SampleRate { get; set; } + public uint BitrateMaximum { get; set; } + public uint NominalBitrate { get; set; } + public uint BitrateMinimum { get; set; } + public byte SmallBlockSizeExponent { get; set; } + public byte LargeBlockSizeExponent { get; set; } + public uint FramingBit { get; set; } = 1u; + + public bool HasData => !string.IsNullOrEmpty(HeaderTag); + + public static VorbisIdentificationPacket ReadData(byte[] packetData) + { + var reader = new ByteChunk(packetData); + var packet = new VorbisIdentificationPacket + { + PacketType = reader.ReadByte(), + HeaderTag = Encoding.ASCII.GetString(reader.ReadBytes(6)), + }; + + if (packet.PacketType != ExpectedPacketType) + throw new InvalidDataException($"Vorbis identification header packet type must be 0x{ExpectedPacketType:X2}."); + if (packet.HeaderTag != ExpectedHeaderTag) + throw new InvalidDataException("Vorbis identification header is missing the expected tag."); + + packet.Version = reader.ReadUInt32(); + packet.Channels = reader.ReadByte(); + packet.SampleRate = reader.ReadUInt32(); + packet.BitrateMaximum = reader.ReadUInt32(); + packet.NominalBitrate = reader.ReadUInt32(); + packet.BitrateMinimum = reader.ReadUInt32(); + + var blockSizeByte = reader.ReadByte(); + packet.SmallBlockSizeExponent = (byte)BitHelper.ExtractBits(blockSizeByte, 0, 4); + packet.LargeBlockSizeExponent = (byte)BitHelper.ExtractBits(blockSizeByte, 4, 4); + + var framingByte = reader.ReadByte(); + packet.FramingBit = (uint)BitHelper.ExtractBits(framingByte, 0, 1); + if (packet.FramingBit != 1u) + throw new InvalidDataException("Vorbis identification header framing flag must be set."); + + if (reader.BytesLeft != 0) + throw new InvalidDataException("Vorbis identification packet contains trailing data."); + + return packet; + } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + stream.Write(ByteParsers.Byte.EncodeValue(PacketType, out _)); + stream.Write(Encoding.ASCII.GetBytes(HeaderTag)); + stream.Write(ByteParsers.UInt32.EncodeValue(Version, out _)); + stream.Write(ByteParsers.Byte.EncodeValue(Channels, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SampleRate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(BitrateMaximum, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(NominalBitrate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(BitrateMinimum, out _)); + + var blockSizeByte = (byte)(BitHelper.ExtractBits(SmallBlockSizeExponent, 0, 4) | (BitHelper.ExtractBits(LargeBlockSizeExponent, 0, 4) << 4)); + stream.Write(ByteParsers.Byte.EncodeValue(blockSizeByte, out _)); + + var framingByte = (byte)BitHelper.ExtractBits(FramingBit, 0, 1); + stream.Write(ByteParsers.Byte.EncodeValue(framingByte, out _)); + + return stream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisSetupPacket.cs b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisSetupPacket.cs new file mode 100644 index 000000000..cde2ae6bf --- /dev/null +++ b/Shared/GameFiles/Audio/Codecs/Vorbis/VorbisSetupPacket.cs @@ -0,0 +1,47 @@ +using System.Text; + +namespace Shared.GameFormats.Audio.Codecs.Vorbis +{ + public class VorbisSetupPacket + { + private const byte ExpectedPacketType = 0x05; + private const string ExpectedHeaderTag = "vorbis"; + private const int HeaderTagByteLength = 6; + private const int PacketPrefixSize = 1 + HeaderTagByteLength; + + public byte PacketType { get; set; } = ExpectedPacketType; + public string HeaderTag { get; set; } = ExpectedHeaderTag; + public byte[] SetupData { get; set; } = []; + + public bool HasData => !string.IsNullOrEmpty(HeaderTag) && SetupData.Length > 0; + + public static VorbisSetupPacket ReadData(byte[] packetData) + { + if (packetData.Length < PacketPrefixSize) + throw new InvalidDataException("Vorbis setup packet is too short."); + + var packet = new VorbisSetupPacket + { + PacketType = packetData[0], + HeaderTag = Encoding.ASCII.GetString(packetData, 1, HeaderTagByteLength), + SetupData = packetData[PacketPrefixSize..], + }; + + if (packet.PacketType != ExpectedPacketType) + throw new InvalidDataException($"Vorbis setup header packet type must be 0x{ExpectedPacketType:X2}."); + if (packet.HeaderTag != ExpectedHeaderTag) + throw new InvalidDataException("Vorbis setup header is missing the expected tag."); + + return packet; + } + + public byte[] WriteData() + { + var data = new byte[PacketPrefixSize + SetupData.Length]; + data[0] = PacketType; + Encoding.ASCII.GetBytes(HeaderTag).CopyTo(data, 1); + SetupData.CopyTo(data, PacketPrefixSize); + return data; + } + } +} diff --git a/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs b/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs deleted file mode 100644 index c90b5d1d7..000000000 --- a/Shared/GameFiles/Audio/Codecs/VorbisAudio.cs +++ /dev/null @@ -1,133 +0,0 @@ -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/VorbisHeaders.cs b/Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs deleted file mode 100644 index a6aeffbb0..000000000 --- a/Shared/GameFiles/Audio/Codecs/VorbisHeaders.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/Containers/Ogg/OggFile.cs b/Shared/GameFiles/Audio/Containers/Ogg/OggFile.cs new file mode 100644 index 000000000..84f99a14f --- /dev/null +++ b/Shared/GameFiles/Audio/Containers/Ogg/OggFile.cs @@ -0,0 +1,220 @@ +using System.Buffers.Binary; +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Codecs.Vorbis; + +namespace Shared.GameFormats.Audio.Containers.Ogg +{ + public class OggFile + { + private const int OggDefaultSerialNumber = 1; + private const int VorbisHeaderPacketCount = 3; + private const double MillisecondsPerSecond = 1000.0; + private const int OggMaxSegmentSize = 255; + 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 int SerialNumber { get; set; } = 1; + public List Packets { get; set; } = []; + + public static OggFile CreateFromWemBytes(byte[] wemBytes) + { + var vorbisAudio = VorbisAudio.CreateFromWemBytes(wemBytes); + + var allPackets = new List(VorbisHeaderPacketCount + vorbisAudio.Packets.Count) + { + new() { PacketData = vorbisAudio.IdentificationHeader.WriteData(), GranulePosition = 0, IsBeginningOfStream = true, IsEndOfStream = false }, + new() { PacketData = vorbisAudio.CommentHeader.WriteData(), GranulePosition = 0, IsBeginningOfStream = false, IsEndOfStream = false }, + new() { PacketData = vorbisAudio.SetupHeader.WriteData(), GranulePosition = 0, IsBeginningOfStream = false, IsEndOfStream = false } + }; + + for (var packetIndex = 0; packetIndex < vorbisAudio.Packets.Count; packetIndex++) + { + var packetData = vorbisAudio.Packets[packetIndex].Data; + long nextTimestampMilliseconds; + if (packetIndex + 1 < vorbisAudio.Packets.Count) + nextTimestampMilliseconds = vorbisAudio.Packets[packetIndex + 1].TimestampMilliseconds; + else + nextTimestampMilliseconds = (long)Math.Round(vorbisAudio.SampleCount * MillisecondsPerSecond / vorbisAudio.SampleRate); + + var granulePosition = (long)Math.Round(nextTimestampMilliseconds * vorbisAudio.SampleRate / MillisecondsPerSecond); + if (packetIndex == vorbisAudio.Packets.Count - 1) + granulePosition = vorbisAudio.SampleCount; + + allPackets.Add(new OggPacket + { + PacketData = packetData, + GranulePosition = Math.Max(0, granulePosition), + IsBeginningOfStream = false, + IsEndOfStream = packetIndex == vorbisAudio.Packets.Count - 1, + }); + } + + return new OggFile + { + SerialNumber = OggDefaultSerialNumber, + Packets = allPackets, + }; + } + + public void ReadData(ByteChunk chunk) + { + Packets = []; + SerialNumber = 1; + + using var packetBuffer = new MemoryStream(); + var hasOpenPacket = false; + var packetStartOnThisPage = false; + var firstPage = true; + + while (chunk.BytesLeft > 0) + { + var pageHeader = OggPageHeader.ReadData(chunk); + + if (chunk.BytesLeft < pageHeader.PayloadSize) + throw new InvalidDataException("Ogg page payload exceeds remaining bytes."); + + if (firstPage) + { + SerialNumber = pageHeader.SerialNumber; + firstPage = false; + } + + var packetCompletedOnThisPageCount = 0; + for (var segmentIndex = 0; segmentIndex < pageHeader.SegmentSizes.Length; segmentIndex++) + { + var segmentSize = pageHeader.SegmentSizes[segmentIndex]; + + if (!hasOpenPacket) + { + hasOpenPacket = true; + packetStartOnThisPage = true; + packetBuffer.SetLength(0); + } + + if (segmentSize > 0) + { + var segmentData = chunk.ReadBytes(segmentSize); + packetBuffer.Write(segmentData); + } + + if (segmentSize != 255) + { + packetCompletedOnThisPageCount++; + Packets.Add(new OggPacket + { + PacketData = packetBuffer.ToArray(), + GranulePosition = pageHeader.GranulePosition, + IsBeginningOfStream = packetStartOnThisPage && packetCompletedOnThisPageCount == 1 && pageHeader.IsBeginningOfStream, + IsEndOfStream = pageHeader.IsEndOfStream && segmentIndex == pageHeader.SegmentSizes.Length - 1, + }); + + hasOpenPacket = false; + } + } + } + + if (hasOpenPacket) + throw new InvalidDataException("Ogg stream ended with an incomplete packet."); + } + + public byte[] WriteData() + { + 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, OggPacket 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."); + + byte headerType = 0; + if (packet.IsBeginningOfStream) + headerType |= OggPageHeader.BeginningOfStreamFlag; + + if (packet.IsEndOfStream) + headerType |= OggPageHeader.EndOfStreamFlag; + + var remainingBytes = packetLength; + var segmentSizes = new byte[segmentCount]; + for (var segmentIndex = 0; segmentIndex < segmentCount; segmentIndex++) + { + byte segmentSize; + if (remainingBytes >= OggMaxSegmentSize) + segmentSize = OggMaxSegmentSize; + else + segmentSize = (byte)remainingBytes; + + segmentSizes[segmentIndex] = segmentSize; + remainingBytes -= OggMaxSegmentSize; + } + + var pageHeader = new OggPageHeader + { + CapturePattern = OggPageHeader.OggCapturePattern, + Version = 0, + HeaderType = headerType, + GranulePosition = packet.GranulePosition, + SerialNumber = serialNumber, + SequenceNumber = sequenceNumber, + Checksum = 0u, + PageSegmentCount = (byte)segmentCount, + SegmentSizes = segmentSizes, + }; + + var headerBytes = pageHeader.WriteData(); + 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/Audio/Ogg/OggAudioPacket.cs b/Shared/GameFiles/Audio/Containers/Ogg/OggPacket.cs similarity index 73% rename from Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs rename to Shared/GameFiles/Audio/Containers/Ogg/OggPacket.cs index ece7c2851..a4533a8e5 100644 --- a/Shared/GameFiles/Audio/Ogg/OggAudioPacket.cs +++ b/Shared/GameFiles/Audio/Containers/Ogg/OggPacket.cs @@ -1,6 +1,6 @@ -namespace Shared.GameFormats.Audio.Ogg +namespace Shared.GameFormats.Audio.Containers.Ogg { - public class OggAudioPacket + public class OggPacket { public byte[] PacketData { get; set; } = []; public long GranulePosition { get; set; } diff --git a/Shared/GameFiles/Audio/Containers/Ogg/OggPageHeader.cs b/Shared/GameFiles/Audio/Containers/Ogg/OggPageHeader.cs new file mode 100644 index 000000000..fc7353087 --- /dev/null +++ b/Shared/GameFiles/Audio/Containers/Ogg/OggPageHeader.cs @@ -0,0 +1,69 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Audio.Containers.Ogg +{ + public class OggPageHeader + { + public const int FixedHeaderSize = 27; + public const string OggCapturePattern = "OggS"; + public const byte BeginningOfStreamFlag = 0x02; + public const byte EndOfStreamFlag = 0x04; + + public string CapturePattern { get; set; } = OggCapturePattern; + public byte Version { get; set; } + public byte HeaderType { get; set; } + public long GranulePosition { get; set; } + public int SerialNumber { get; set; } + public int SequenceNumber { get; set; } + public uint Checksum { get; set; } + public byte PageSegmentCount { get; set; } + public byte[] SegmentSizes { get; set; } = []; + + public bool IsBeginningOfStream => (HeaderType & BeginningOfStreamFlag) != 0; + public bool IsEndOfStream => (HeaderType & EndOfStreamFlag) != 0; + public int PayloadSize => SegmentSizes.Sum(segmentSize => segmentSize); + + public static OggPageHeader ReadData(ByteChunk chunk) + { + if (chunk.BytesLeft < FixedHeaderSize) + throw new InvalidDataException("Ogg stream ended before a complete page header could be read."); + + var header = new OggPageHeader + { + CapturePattern = chunk.ReadFixedLength(4), + Version = chunk.ReadByte(), + HeaderType = chunk.ReadByte(), + GranulePosition = chunk.ReadInt64(), + SerialNumber = chunk.ReadInt32(), + SequenceNumber = chunk.ReadInt32(), + Checksum = chunk.ReadUInt32(), + PageSegmentCount = chunk.ReadByte(), + }; + + if (header.CapturePattern != OggCapturePattern) + throw new InvalidDataException($"Invalid Ogg capture pattern '{header.CapturePattern}'."); + + if (chunk.BytesLeft < header.PageSegmentCount) + throw new InvalidDataException("Ogg page segment table exceeds remaining bytes."); + + header.SegmentSizes = chunk.ReadBytes(header.PageSegmentCount); + return header; + } + + public byte[] WriteData() + { + using var header = new MemoryStream(FixedHeaderSize + SegmentSizes.Length); + header.Write(Encoding.ASCII.GetBytes(CapturePattern)); + header.Write(ByteParsers.Byte.EncodeValue(Version, out _)); + header.Write(ByteParsers.Byte.EncodeValue(HeaderType, out _)); + header.Write(ByteParsers.Int64.EncodeValue(GranulePosition, out _)); + header.Write(ByteParsers.Int32.EncodeValue(SerialNumber, out _)); + header.Write(ByteParsers.Int32.EncodeValue(SequenceNumber, out _)); + header.Write(ByteParsers.UInt32.EncodeValue(Checksum, out _)); + header.Write(ByteParsers.Byte.EncodeValue(PageSegmentCount, out _)); + header.Write(SegmentSizes); + return header.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Audio/Wav/DataChunk.cs b/Shared/GameFiles/Audio/Containers/Wav/DataChunk.cs similarity index 90% rename from Shared/GameFiles/Audio/Wav/DataChunk.cs rename to Shared/GameFiles/Audio/Containers/Wav/DataChunk.cs index bc5d76e3f..3d16bf070 100644 --- a/Shared/GameFiles/Audio/Wav/DataChunk.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/DataChunk.cs @@ -1,6 +1,6 @@ using Shared.ByteParsing; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public class DataChunk : RiffChunk { diff --git a/Shared/GameFiles/Audio/Wav/FmtChunk.cs b/Shared/GameFiles/Audio/Containers/Wav/FmtChunk.cs similarity index 97% rename from Shared/GameFiles/Audio/Wav/FmtChunk.cs rename to Shared/GameFiles/Audio/Containers/Wav/FmtChunk.cs index 7c8ffa9be..f337211cd 100644 --- a/Shared/GameFiles/Audio/Wav/FmtChunk.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/FmtChunk.cs @@ -1,6 +1,6 @@ using Shared.ByteParsing; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public class FmtChunk : RiffChunk { diff --git a/Shared/GameFiles/Audio/Wav/RiffChunk.cs b/Shared/GameFiles/Audio/Containers/Wav/RiffChunk.cs similarity index 93% rename from Shared/GameFiles/Audio/Wav/RiffChunk.cs rename to Shared/GameFiles/Audio/Containers/Wav/RiffChunk.cs index 765f565e7..66ef3a314 100644 --- a/Shared/GameFiles/Audio/Wav/RiffChunk.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/RiffChunk.cs @@ -2,7 +2,7 @@ using Shared.ByteParsing; using Shared.Core.ErrorHandling; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public abstract class RiffChunk { diff --git a/Shared/GameFiles/Audio/Wav/WavChunkFactory.cs b/Shared/GameFiles/Audio/Containers/Wav/WavChunkFactory.cs similarity index 86% rename from Shared/GameFiles/Audio/Wav/WavChunkFactory.cs rename to Shared/GameFiles/Audio/Containers/Wav/WavChunkFactory.cs index 203289779..00cead12e 100644 --- a/Shared/GameFiles/Audio/Wav/WavChunkFactory.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/WavChunkFactory.cs @@ -1,4 +1,4 @@ -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public static class WavChunkFactory { diff --git a/Shared/GameFiles/Audio/Wav/WavChunkHeader.cs b/Shared/GameFiles/Audio/Containers/Wav/WavChunkHeader.cs similarity index 96% rename from Shared/GameFiles/Audio/Wav/WavChunkHeader.cs rename to Shared/GameFiles/Audio/Containers/Wav/WavChunkHeader.cs index 3284a3e4c..e9f0fc4d8 100644 --- a/Shared/GameFiles/Audio/Wav/WavChunkHeader.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/WavChunkHeader.cs @@ -1,7 +1,7 @@ using System.Text; using Shared.ByteParsing; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public class WavChunkHeader { diff --git a/Shared/GameFiles/Audio/Wav/WavFile.cs b/Shared/GameFiles/Audio/Containers/Wav/WavFile.cs similarity index 94% rename from Shared/GameFiles/Audio/Wav/WavFile.cs rename to Shared/GameFiles/Audio/Containers/Wav/WavFile.cs index 97cd98715..351da98b9 100644 --- a/Shared/GameFiles/Audio/Wav/WavFile.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/WavFile.cs @@ -1,7 +1,7 @@ using Shared.ByteParsing; -using Shared.GameFormats.Audio.Codecs; +using Shared.GameFormats.Audio.Formats.Pcm; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public class WavFile { @@ -12,11 +12,10 @@ public class WavFile public DataChunk DataChunk { get; set; } = new(); public PcmAudio Audio { get; set; } = new(); - public static WavFile CreateFromBytes(byte[] wavData) + public static WavFile CreateFromBytes(byte[] wavBytes) { - ArgumentNullException.ThrowIfNull(wavData); var wavFile = new WavFile(); - wavFile.ReadData(new ByteChunk(wavData)); + wavFile.ReadData(new ByteChunk(wavBytes)); return wavFile; } diff --git a/Shared/GameFiles/Audio/Wav/WavFileHeader.cs b/Shared/GameFiles/Audio/Containers/Wav/WavFileHeader.cs similarity index 97% rename from Shared/GameFiles/Audio/Wav/WavFileHeader.cs rename to Shared/GameFiles/Audio/Containers/Wav/WavFileHeader.cs index 941fe2801..7cb7f2979 100644 --- a/Shared/GameFiles/Audio/Wav/WavFileHeader.cs +++ b/Shared/GameFiles/Audio/Containers/Wav/WavFileHeader.cs @@ -1,7 +1,7 @@ using System.Text; using Shared.ByteParsing; -namespace Shared.GameFormats.Audio.Wav +namespace Shared.GameFormats.Audio.Containers.Wav { public class WavFileHeader { diff --git a/Shared/GameFiles/Audio/Formats/Pcm/PcmAudio.cs b/Shared/GameFiles/Audio/Formats/Pcm/PcmAudio.cs new file mode 100644 index 000000000..2547cc909 --- /dev/null +++ b/Shared/GameFiles/Audio/Formats/Pcm/PcmAudio.cs @@ -0,0 +1,57 @@ +using NVorbis; +using Shared.ByteParsing; +using Shared.GameFormats.Audio.Containers.Ogg; + +namespace Shared.GameFormats.Audio.Formats.Pcm +{ + public class PcmAudio + { + private const float MinPcmSampleValue = -1.0f; + private const float MaxPcmSampleValue = 1.0f; + private const int PcmBitsPerSample = 16; + private const int PcmSampleReadBufferSize = 4096; + + 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; + + public static PcmAudio CreateFromWemBytes(byte[] wemBytes) + { + var oggBytes = OggFile.CreateFromWemBytes(wemBytes).WriteData(); + return CreateFromOggBytes(oggBytes); + } + + public static PcmAudio CreateFromOggBytes(byte[] oggData) + { + 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, + }; + } + } +} diff --git a/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs b/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs deleted file mode 100644 index 7c3734831..000000000 --- a/Shared/GameFiles/Audio/Ogg/OggSerialiser.cs +++ /dev/null @@ -1,112 +0,0 @@ -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/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs index 36e0f77b1..fb6fdc693 100644 --- a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisCommentHeader.cs @@ -12,23 +12,26 @@ public class VorbisCommentHeader 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); + using var stream = new MemoryStream(); + stream.Write(ByteParsers.Byte.EncodeValue(PacketType, out _)); + stream.Write(System.Text.Encoding.ASCII.GetBytes(HeaderTag)); + var vendorBytes = System.Text.Encoding.ASCII.GetBytes(VendorString); + stream.Write(ByteParsers.UInt32.EncodeValue((uint)vendorBytes.Length, out _)); + stream.Write(vendorBytes); + + stream.Write(ByteParsers.UInt32.EncodeValue((uint)UserComments.Count, out _)); foreach (var comment in UserComments) { - writer.WriteBits((uint)comment.Length, 32); - writer.WriteAscii(comment); + var commentBytes = System.Text.Encoding.ASCII.GetBytes(comment); + stream.Write(ByteParsers.UInt32.EncodeValue((uint)commentBytes.Length, out _)); + stream.Write(commentBytes); } - writer.WriteBits(FramingBit, 1); - writer.AlignToByte(); + var framingByte = (byte)BitHelper.ExtractBits(FramingBit, 0, 1); + stream.Write(ByteParsers.Byte.EncodeValue(framingByte, out _)); - return writer.ToArray(); + return stream.ToArray(); } } } diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs index 442bc48a0..18c5df5be 100644 --- a/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/VorbisIdentificationHeader.cs @@ -18,20 +18,23 @@ public class VorbisIdentificationHeader(WemFile wemFile) 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(); + using var stream = new MemoryStream(); + stream.Write(ByteParsers.Byte.EncodeValue(PacketType, out _)); + stream.Write(System.Text.Encoding.ASCII.GetBytes(HeaderTag)); + stream.Write(ByteParsers.UInt32.EncodeValue(Version, out _)); + stream.Write(ByteParsers.Byte.EncodeValue(Channels, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(SampleRate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(BitrateMaximum, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(NominalBitrate, out _)); + stream.Write(ByteParsers.UInt32.EncodeValue(BitrateMinimum, out _)); + + var blockSizeByte = (byte)(BitHelper.ExtractBits(SmallBlockSizeExponent, 0, 4) | (BitHelper.ExtractBits(LargeBlockSizeExponent, 0, 4) << 4)); + stream.Write(ByteParsers.Byte.EncodeValue(blockSizeByte, out _)); + + var framingByte = (byte)BitHelper.ExtractBits(FramingBit, 0, 1); + stream.Write(ByteParsers.Byte.EncodeValue(framingByte, out _)); + + return stream.ToArray(); } } } diff --git a/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs b/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs index b95ab77cd..fae55ed88 100644 --- a/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs +++ b/Shared/GameFiles/Wwise/Wem/V132/Decoding/WemVorbisDecoder.cs @@ -1,6 +1,5 @@ using Shared.ByteParsing; -using Shared.GameFormats.Audio.Codecs; -using Shared.GameFormats.Wwise.Wem.V132; +using Shared.GameFormats.Audio.Codecs.Vorbis; using Shared.GameFormats.Wwise.Wem.V132.Encoding; namespace Shared.GameFormats.Wwise.Wem.V132.Decoding @@ -14,12 +13,6 @@ public class WemVorbisDecoder(WwiseCodebookLibrary codebookLibrary) 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 { @@ -29,12 +22,9 @@ private VorbisDecodeResult BuildDecodeResult(WemFile wemFile) 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); @@ -67,6 +57,9 @@ private static VorbisAudio BuildVorbis(WemFile wemFile, VorbisDecodeResult decod { Channels = checked((byte)wemFile.FmtChunk.Channels), VorbisCodecPrivateData = BuildXiphLacedCodecPrivate(decodeResult.IdentificationPacket, decodeResult.CommentPacket, decodeResult.SetupPacket), + IdentificationHeader = VorbisIdentificationPacket.ReadData(decodeResult.IdentificationPacket), + CommentHeader = VorbisCommentPacket.ReadData(decodeResult.CommentPacket), + SetupHeader = VorbisSetupPacket.ReadData(decodeResult.SetupPacket), Packets = vorbisPackets, SampleCount = wemFile.FmtChunk.SampleCount, SampleRate = wemFile.FmtChunk.SampleRate, diff --git a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs index a1b965838..60a679433 100644 --- a/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs +++ b/Shared/GameFiles/Wwise/Wem/V132/Encoding/WemVorbisEncoder.cs @@ -1,7 +1,7 @@ using Shared.ByteParsing; -using Shared.GameFormats.Audio.Codecs; -using Shared.GameFormats.Audio.Ogg; -using Shared.GameFormats.Audio.Wav; +using Shared.GameFormats.Audio.Containers.Ogg; +using Shared.GameFormats.Audio.Containers.Wav; +using Shared.GameFormats.Audio.Formats.Pcm; namespace Shared.GameFormats.Wwise.Wem.V132.Encoding { @@ -14,10 +14,9 @@ public class WemVorbisEncoder(WwiseCodebookLibrary codebookLibrary) private const int EncodingWriteBufferSize = 512; private const float DefaultSilenceDbFloor = -96.0f; - public WemFile EncodeFromWav(byte[] wavData, WemEncodingSettings? encodingSettings = null) + public WemFile EncodeFromWavBytes(byte[] wavBytes, WemEncodingSettings? encodingSettings = null) { - ArgumentNullException.ThrowIfNull(wavData); - var wavFile = WavFile.CreateFromBytes(wavData); + var wavFile = WavFile.CreateFromBytes(wavBytes); var perChannelSamples = ConvertPcmToPerChannelFloat(wavFile.Audio, wavFile.FmtChunk.FormatTag); return EncodeFloatSamplesToWem(perChannelSamples, checked(wavFile.Audio.Channels), checked((int)wavFile.Audio.SampleRate)); } @@ -47,9 +46,9 @@ private WemFile EncodeFloatSamplesToWem(float[][] perChannelSamples, int channel } processingState.WriteEndOfStream(); - var oggAudioPackets = new List(); + var oggAudioPackets = new List(); while (processingState.PacketOut(out var oggPacket)) - oggAudioPackets.Add(new OggAudioPacket { PacketData = oggPacket.PacketData, GranulePosition = oggPacket.GranulePosition }); + oggAudioPackets.Add(new OggPacket { PacketData = oggPacket.PacketData, GranulePosition = oggPacket.GranulePosition }); var wemSetupData = CompressSetup(booksPacket.PacketData, channels); var previousPacketIsLargeBlock = false; diff --git a/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs b/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs index b238c68e2..f9a0f920b 100644 --- a/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs +++ b/Shared/GameFiles/Wwise/Wem/V132/WemFile.cs @@ -13,23 +13,20 @@ public class WemFile public CueChunk? CueChunk { get; set; } public List UnknownChunks { get; set; } = []; - public static WemFile CreateFromBytes(byte[] wemData) + public static WemFile CreateFromWemBytes(byte[] wemBytes) { - ArgumentNullException.ThrowIfNull(wemData); var wemFile = new WemFile(); - wemFile.ReadData(new ByteChunk(wemData)); + wemFile.ReadData(new ByteChunk(wemBytes)); return wemFile; } - public static WemFile CreateFromWavBytes(byte[] wavData, WemEncodingSettings? encodingSettings = null) + public static WemFile CreateFromWavBytes(byte[] wavBytes, 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); + return encoder.EncodeFromWavBytes(wavBytes); } public void ReadData(ByteChunk chunk) From 74315fd663659b8ed4665b5bb6f0c931fe085357 Mon Sep 17 00:00:00 2001 From: Pear-231 <61670316+Pear-231@users.noreply.github.com> Date: Sat, 16 May 2026 12:49:22 +0100 Subject: [PATCH 2/2] Added movie exporting --- .../ContextMenu/ExportCAVp8AsIvfCommand.cs | 37 ++ .../ContextMenu/ExportCAVp8AsWebMCommand.cs | 52 +++ Editors/Audio/DependencyInjectionContainer.cs | 18 + .../Audio/Shared/Utilities/CAVp8Exporter.cs | 36 ++ .../Shared/Utilities/MovieAudioResolver.cs | 45 +++ Shared/GameFiles/Video/CAVp8File.cs | 173 ++++++++ Shared/GameFiles/Video/ClusterBlock.cs | 4 + Shared/GameFiles/Video/FrameTableRecord.cs | 9 + Shared/GameFiles/Video/IvfFile.cs | 102 +++++ Shared/GameFiles/Video/WebMFile.cs | 379 ++++++++++++++++++ 10 files changed, 855 insertions(+) create mode 100644 Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs create mode 100644 Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs create mode 100644 Editors/Audio/Shared/Utilities/CAVp8Exporter.cs create mode 100644 Editors/Audio/Shared/Utilities/MovieAudioResolver.cs create mode 100644 Shared/GameFiles/Video/CAVp8File.cs create mode 100644 Shared/GameFiles/Video/ClusterBlock.cs create mode 100644 Shared/GameFiles/Video/FrameTableRecord.cs create mode 100644 Shared/GameFiles/Video/IvfFile.cs create mode 100644 Shared/GameFiles/Video/WebMFile.cs diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs new file mode 100644 index 000000000..a6ce81bbe --- /dev/null +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsIvfCommand.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using Editors.Audio.Shared.Utilities; +using Shared.Core.Misc; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Editors.Audio.ContextMenu +{ + public class ExportCAVp8AsIvfCommand(IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand + { + private readonly IStandardDialogs _standardDialogs = standardDialogs; + private readonly IFileSystemAccess _fileSystemAccess = fileSystemAccess; + + public string GetDisplayName(TreeNode node) => "Export as IVF"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + + public void Execute(TreeNode selectedNode) + { + var packFile = selectedNode.Item; + if (packFile == null) + return; + + var dialogResult = _standardDialogs.ShowSystemFolderBrowserDialog(); + if (!dialogResult.Result || string.IsNullOrWhiteSpace(dialogResult.FolderPath)) + return; + + DirectoryHelper.EnsureCreated(dialogResult.FolderPath); + + var ivfPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(packFile.Name, ".ivf")); + var ivfBytes = CAVp8Exporter.ExportToIvf(packFile); + _fileSystemAccess.FileWriteAllBytes(ivfPath, ivfBytes); + } + } +} diff --git a/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs new file mode 100644 index 000000000..644e25709 --- /dev/null +++ b/Editors/Audio/ContextMenu/ExportCAVp8AsWebMCommand.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using Editors.Audio.Shared.GameInformation.Warhammer3; +using Editors.Audio.Shared.Storage; +using Editors.Audio.Shared.Utilities; +using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; + +namespace Editors.Audio.ContextMenu +{ + public class ExportCAVp8AsWebMCommand( + IStandardDialogs standardDialogs, + IFileSystemAccess fileSystemAccess, + IPackFileService packFileService, + IAudioRepository audioRepository, + IMovieAudioResolver movieAudioResolver) : IContextMenuCommand + { + private readonly IStandardDialogs _standardDialogs = standardDialogs; + private readonly IFileSystemAccess _fileSystemAccess = fileSystemAccess; + private readonly IPackFileService _packFileService = packFileService; + private readonly IAudioRepository _audioRepository = audioRepository; + private readonly IMovieAudioResolver _movieAudioResolver = movieAudioResolver; + + public string GetDisplayName(TreeNode node) => "Export as WebM"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && node.Item != null; + public bool IsEnabled(TreeNode node) => node.Item != null && node.Item.Name.EndsWith(".ca_vp8", StringComparison.OrdinalIgnoreCase); + + public void Execute(TreeNode selectedNode) + { + var caVp8PackFile = selectedNode.Item; + if (caVp8PackFile == null) + return; + + var dialogResult = _standardDialogs.ShowSystemFolderBrowserDialog(); + if (!dialogResult.Result || string.IsNullOrWhiteSpace(dialogResult.FolderPath)) + return; + + DirectoryHelper.EnsureCreated(dialogResult.FolderPath); + + _audioRepository.Load(Wh3LanguageInformation.GetAllLanguages()); + + var caVp8PackFilePath = _packFileService.GetFullPath(caVp8PackFile); + var wemPackFile = _movieAudioResolver.ResolveMovieWem(caVp8PackFilePath); + var webMPath = Path.Combine(dialogResult.FolderPath, Path.ChangeExtension(caVp8PackFile.Name, ".webm")); + var webMBytes = CAVp8Exporter.ExportToWebM(caVp8PackFile, wemPackFile); + _fileSystemAccess.FileWriteAllBytes(webMPath, webMBytes); + } + } +} diff --git a/Editors/Audio/DependencyInjectionContainer.cs b/Editors/Audio/DependencyInjectionContainer.cs index 1805e53c5..f2ca4bf0d 100644 --- a/Editors/Audio/DependencyInjectionContainer.cs +++ b/Editors/Audio/DependencyInjectionContainer.cs @@ -18,12 +18,14 @@ using Editors.Audio.AudioExplorer; using Editors.Audio.AudioProjectConverter; using Editors.Audio.AudioProjectMerger; +using Editors.Audio.ContextMenu; using Editors.Audio.DialogueEventMerger; using Editors.Audio.Shared.AudioProject; using Editors.Audio.Shared.AudioProject.Compiler; using Editors.Audio.Shared.AudioProject.Factories; using Editors.Audio.Shared.Dat; using Editors.Audio.Shared.Storage; +using Editors.Audio.Shared.Utilities; using Editors.Audio.Shared.Wwise; using Editors.Audio.Shared.Wwise.Generators; using Editors.Audio.WaveformVisualiser.Presentation; @@ -31,6 +33,7 @@ using Shared.Core.DependencyInjection; using Shared.Core.DevConfig; using Shared.Core.ToolCreation; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; namespace Editors.Audio { @@ -129,9 +132,15 @@ public override void Register(IServiceCollection serviceCollection) // Shared audio stuff serviceCollection.AddScoped(); + serviceCollection.AddScoped(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + // Context menu + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddSingleton(); + RegisterAllAsInterface(serviceCollection, ServiceLifetime.Transient); } @@ -148,4 +157,13 @@ public override void RegisterTools(IEditorDatabase factory) .Build(factory); } } + + public class AudioPackFileContextMenuRegistration : IPackFileContextMenuRegistration + { + public void Register(PackFileContextMenuRegistry registry) + { + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 20, ContextMenuCluster.Export); + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 30, ContextMenuCluster.Export); + } + } } diff --git a/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs b/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs new file mode 100644 index 000000000..6963dc19c --- /dev/null +++ b/Editors/Audio/Shared/Utilities/CAVp8Exporter.cs @@ -0,0 +1,36 @@ +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Audio.Codecs.Vorbis; +using Shared.GameFormats.Video; + +namespace Editors.Audio.Shared.Utilities +{ + public static class CAVp8Exporter + { + public static byte[] ExportToWebM(PackFile caVp8PackFile, PackFile wemPackFile) + { + var vorbisAudio = VorbisAudio.CreateFromWemBytes(wemPackFile.DataSource.ReadData()); + var caVp8File = new CAVp8File(caVp8PackFile.DataSource.ReadData()); + var webMFile = new WebMFile + { + Width = caVp8File.Width, + Height = caVp8File.Height, + Framerate = caVp8File.Framerate, + FrameTable = caVp8File.FrameTable, + FrameData = caVp8File.FrameData, + VorbisCodecPrivate = vorbisAudio.VorbisCodecPrivateData, + VorbisAudioPackets = vorbisAudio.Packets, + VorbisSampleRate = checked((int)vorbisAudio.SampleRate), + VorbisChannels = vorbisAudio.Channels, + }; + return webMFile.WriteData(); + } + + public static byte[] ExportToIvf(PackFile caVp8PackFile) + { + var caVp8File = new CAVp8File(caVp8PackFile.DataSource.ReadData()); + var ivfFile = new IvfFile(caVp8File); + return ivfFile.WriteData(); + } + } +} diff --git a/Editors/Audio/Shared/Utilities/MovieAudioResolver.cs b/Editors/Audio/Shared/Utilities/MovieAudioResolver.cs new file mode 100644 index 000000000..2bd55be3c --- /dev/null +++ b/Editors/Audio/Shared/Utilities/MovieAudioResolver.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using Editors.Audio.Shared.GameInformation.Warhammer3; +using Editors.Audio.Shared.Storage; +using Editors.Audio.Shared.Wwise.HircExploration; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.Wwise; +using Shared.GameFormats.Wwise.Hirc; + +namespace Editors.Audio.Shared.Utilities +{ + public interface IMovieAudioResolver + { + PackFile ResolveMovieWem(string caVp8PackFilePath); + } + + public class MovieAudioResolver(IAudioRepository audioRepository) : IMovieAudioResolver + { + private readonly IAudioRepository _audioRepository = audioRepository; + + public PackFile ResolveMovieWem(string caVp8PackFilePath) + { + var actionEventName = Wh3ActionEventInformation.GetMovieActionEventName(caVp8PackFilePath); + var actionEventId = WwiseHash.Compute(actionEventName); + + var actionEventHircs = _audioRepository.GetHircs(actionEventId); + if (actionEventHircs.Count == 0) + throw new Exception($"Cannot find Action Event: {actionEventName}."); + + var hircTreeChildrenParser = new HircTreeChildrenParser(_audioRepository); + var nodes = hircTreeChildrenParser.BuildHierarchyAsFlatList(actionEventHircs.First()); + + var sound = nodes.FirstOrDefault(node => node.Hirc is ICAkSound)?.Hirc as ICAkSound; + if (sound == null) + throw new Exception($"Cannot find a Sound for Action Event: {actionEventName}."); + + var sourceId = sound.GetSourceId(); + var wemPackFile = _audioRepository.FindWem(sourceId.ToString()); + if (wemPackFile == null) + throw new Exception($"Cannot find {sourceId}.wem"); + + return wemPackFile; + } + } +} \ No newline at end of file diff --git a/Shared/GameFiles/Video/CAVp8File.cs b/Shared/GameFiles/Video/CAVp8File.cs new file mode 100644 index 000000000..a8e05c904 --- /dev/null +++ b/Shared/GameFiles/Video/CAVp8File.cs @@ -0,0 +1,173 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Video +{ + public class CAVp8ExtraHeaderData + { + public byte UnknownByte { get; set; } + public uint UnknownUInt32First { get; set; } + public uint UnknownUInt32Second { get; set; } + } + + public class CAVp8File + { + private const string Signature = "CAMV"; + private const ushort HeaderLengthV0 = 40; + private const ushort HeaderLengthV1 = 40; + + public ushort Version { get; set; } + public string CodecFourCC { get; set; } = "VP80"; + public ushort Width { get; set; } + public ushort Height { get; set; } + public uint NumberOfFrames { get; set; } + public uint LargestFrameSize { get; set; } + public float Framerate { get; set; } + public CAVp8ExtraHeaderData? ExtraData { get; set; } + public List FrameTable { get; set; } = []; + public byte[] FrameData { get; set; } = []; + + public CAVp8File(byte[] data) + { + ReadData(new ByteChunk(data)); + } + + private void ReadData(ByteChunk chunk) + { + var bytes = chunk.ReadBytes(chunk.BytesLeft); + using var reader = new BinaryReader(new MemoryStream(bytes)); + + var signature = Encoding.ASCII.GetString(reader.ReadBytes(4)); + if (signature != Signature) + throw new Exception($"CA_VP8 signature mismatch: expected '{Signature}' but got '{signature}'."); + + Version = reader.ReadUInt16(); + var rawHeaderLength = reader.ReadUInt16(); + CodecFourCC = Encoding.ASCII.GetString(reader.ReadBytes(4)); + Width = reader.ReadUInt16(); + Height = reader.ReadUInt16(); + var msPerFrame = reader.ReadSingle(); + reader.ReadUInt32(); + var numFramesMinusOne = reader.ReadUInt32(); + var offsetFrameTable = reader.ReadUInt32(); + NumberOfFrames = reader.ReadUInt32(); + LargestFrameSize = reader.ReadUInt32(); + + // When it's the same, there are 9 extra bytes in the header + if (numFramesMinusOne == NumberOfFrames) + { + ExtraData = new CAVp8ExtraHeaderData + { + UnknownByte = reader.ReadByte(), + UnknownUInt32First = reader.ReadUInt32(), + UnknownUInt32Second = reader.ReadUInt32(), + }; + } + + var expectedHeaderEnd = (long)(rawHeaderLength + 8); + if (reader.BaseStream.Position != expectedHeaderEnd) + throw new Exception($"CA_VP8 header size mismatch: expected stream to be at position {expectedHeaderEnd} after reading the header, but it is at position {reader.BaseStream.Position}."); + + var frameDataLength = (int)(offsetFrameTable - reader.BaseStream.Position); + FrameData = reader.ReadBytes(frameDataLength); + + var totalFileLength = reader.BaseStream.Length; + var frameTableLength = totalFileLength - reader.BaseStream.Position; + var hasBells = frameTableLength / 13 == NumberOfFrames && frameTableLength % 13 == 0; + + var runningOffset = 0u; + FrameTable = []; + using var frameTableDecoded = new MemoryStream(); + + for (var frameIndex = 0; frameIndex < NumberOfFrames; frameIndex++) + { + var frameOffsetReal = reader.ReadUInt32(); + var frameSize = reader.ReadUInt32(); + if (hasBells) + reader.ReadUInt32(); + var isKeyFrame = reader.ReadBoolean(); + + var frame = new FrameTableRecord + { + Offset = runningOffset, + Size = frameSize, + IsKeyFrame = isKeyFrame, + }; + + runningOffset += frame.Size; + FrameTable.Add(frame); + + var frameOffsetRealEnd = frameOffsetReal + frameSize; + if (frameOffsetRealEnd > totalFileLength) + throw new Exception($"CA_VP8 frame {frameIndex} has an incorrect or unknown frame size: it would end at file offset {frameOffsetRealEnd} which is beyond the end of the file at {totalFileLength}."); + + var savedPosition = reader.BaseStream.Position; + reader.BaseStream.Seek(frameOffsetReal, SeekOrigin.Begin); + frameTableDecoded.Write(reader.ReadBytes((int)frameSize)); + reader.BaseStream.Seek(savedPosition, SeekOrigin.Begin); + } + + if (reader.BaseStream.Position != totalFileLength) + throw new Exception($"CA_VP8 file size mismatch: expected {totalFileLength} bytes but stream is at position {reader.BaseStream.Position}."); + + Framerate = 1000f / msPerFrame; + } + + public byte[] WriteData() + { + using var memStream = new MemoryStream(); + using var writer = new BinaryWriter(memStream); + + ushort headerLength; + if (Version == 0) + headerLength = ExtraData != null ? (ushort)(HeaderLengthV0 + 9) : HeaderLengthV0; + else + headerLength = ExtraData != null ? (ushort)(HeaderLengthV1 + 9) : HeaderLengthV1; + + var rawHeaderLength = (ushort)(headerLength - 8); + + writer.Write(Encoding.ASCII.GetBytes(Signature)); + writer.Write(Version); + writer.Write(rawHeaderLength); + writer.Write(Encoding.ASCII.GetBytes(CodecFourCC)); + writer.Write(Width); + writer.Write(Height); + writer.Write(1000f / Framerate); + writer.Write((uint)1); + + if (ExtraData != null || NumberOfFrames == 0) + writer.Write(NumberOfFrames); + else + writer.Write(NumberOfFrames - 1); + + writer.Write((uint)(headerLength + FrameData.Length)); + writer.Write(NumberOfFrames); + writer.Write(FrameTable.Max(frame => frame.Size)); + + if (ExtraData != null) + { + writer.Write(ExtraData.UnknownByte); + writer.Write(ExtraData.UnknownUInt32First); + writer.Write(ExtraData.UnknownUInt32Second); + } + + writer.Write(FrameData); + + uint runningOffset; + if (ExtraData != null) + runningOffset = rawHeaderLength; + else + runningOffset = headerLength; + + foreach (var frame in FrameTable) + { + writer.Write(runningOffset); + writer.Write(frame.Size); + writer.Write(frame.IsKeyFrame); + runningOffset += frame.Size; + } + + return memStream.ToArray(); + } + } +} diff --git a/Shared/GameFiles/Video/ClusterBlock.cs b/Shared/GameFiles/Video/ClusterBlock.cs new file mode 100644 index 000000000..b02a328b9 --- /dev/null +++ b/Shared/GameFiles/Video/ClusterBlock.cs @@ -0,0 +1,4 @@ +namespace Shared.GameFormats.Video +{ + public record ClusterBlock(long TimestampMs, byte[] Data); +} diff --git a/Shared/GameFiles/Video/FrameTableRecord.cs b/Shared/GameFiles/Video/FrameTableRecord.cs new file mode 100644 index 000000000..a5f5c55b7 --- /dev/null +++ b/Shared/GameFiles/Video/FrameTableRecord.cs @@ -0,0 +1,9 @@ +namespace Shared.GameFormats.Video +{ + public class FrameTableRecord + { + public uint Offset { get; set; } + public uint Size { get; set; } + public bool IsKeyFrame { get; set; } + } +} diff --git a/Shared/GameFiles/Video/IvfFile.cs b/Shared/GameFiles/Video/IvfFile.cs new file mode 100644 index 000000000..d3d5e9e8f --- /dev/null +++ b/Shared/GameFiles/Video/IvfFile.cs @@ -0,0 +1,102 @@ +using System.Text; +using Shared.ByteParsing; + +namespace Shared.GameFormats.Video +{ + public class IvfFile(CAVp8File caVp8File) + { + private const string Signature = "DKIF"; + private const ushort HeaderLength = 32; + + public ushort Version { get; set; } = caVp8File.Version; + public string CodecFourCC { get; set; } = caVp8File.CodecFourCC; + public ushort Width { get; set; } = caVp8File.Width; + public ushort Height { get; set; } = caVp8File.Height; + public uint NumberOfFrames { get; set; } = caVp8File.NumberOfFrames; + public float Framerate { get; set; } = caVp8File.Framerate; + public List FrameTable { get; set; } = caVp8File.FrameTable; + public byte[] FrameData { get; set; } = caVp8File.FrameData; + + public byte[] WriteData() + { + using var memStream = new MemoryStream(); + using var writer = new BinaryWriter(memStream); + + writer.Write(Encoding.ASCII.GetBytes(Signature)); + writer.Write(Version); + writer.Write(HeaderLength); + writer.Write(Encoding.ASCII.GetBytes(CodecFourCC)); + writer.Write(Width); + writer.Write(Height); + + var (framerateNumerator, framerateDenominator) = ConvertFramerateToRational(Framerate); + writer.Write(framerateNumerator); + writer.Write(framerateDenominator); + + writer.Write(NumberOfFrames); + writer.Write((uint)0); + + var offset = 0; + for (var frameIndex = 0; frameIndex < FrameTable.Count; frameIndex++) + { + var frame = FrameTable[frameIndex]; + + var frameEndMinusOne = offset + (int)frame.Size - 1; + if (frameEndMinusOne < 0) + throw new Exception($"IVF frame {frameIndex} has a size of zero at offset zero, which causes integer underflow when computing the last byte index."); + + if (offset < FrameData.Length && frameEndMinusOne < FrameData.Length) + { + var frameData = FrameData[offset..(offset + (int)frame.Size)]; + writer.Write((uint)frameData.Length); + writer.Write((ulong)frameIndex); + writer.Write(frameData); + offset += (int)frame.Size; + } + } + + return memStream.ToArray(); + } + + private static (uint numerator, uint denominator) ConvertFramerateToRational(float framerate) + { + if (framerate <= 0f || float.IsNaN(framerate) || float.IsInfinity(framerate)) + throw new Exception($"Cannot convert a framerate of {framerate} to a rational number."); + + var bits = BitConverter.SingleToUInt32Bits(framerate); + var biasedExponent = (int)BitHelper.ExtractBits(bits, 23, 8); + var mantissaBits = BitHelper.ExtractBits(bits, 0, 23); + + ulong significand = (1UL << 23) | mantissaBits; + var exponentShift = biasedExponent - 150; + + ulong numerator; + ulong denominator; + + if (exponentShift >= 0) + { + numerator = significand << exponentShift; + denominator = 1; + } + else + { + numerator = significand; + denominator = 1UL << (-exponentShift); + } + + var divisor = GreatestCommonDivisor(numerator, denominator); + return ((uint)(numerator / divisor), (uint)(denominator / divisor)); + } + + private static ulong GreatestCommonDivisor(ulong a, ulong b) + { + while (b != 0) + { + var remainder = a % b; + a = b; + b = remainder; + } + return a; + } + } +} diff --git a/Shared/GameFiles/Video/WebMFile.cs b/Shared/GameFiles/Video/WebMFile.cs new file mode 100644 index 000000000..404f5fd0f --- /dev/null +++ b/Shared/GameFiles/Video/WebMFile.cs @@ -0,0 +1,379 @@ +using System.Text; +using Shared.GameFormats.Audio.Codecs.Vorbis; + +namespace Shared.GameFormats.Video +{ + public class WebMFile + { + private const uint IdTracks = 0x1654AE6B; + private const uint IdTrackEntry = 0xAE; + private const uint IdTrackNumber = 0xD7; + private const uint IdTrackUid = 0x73C5; + private const uint IdTrackType = 0x83; + private const uint IdFlagLacing = 0x9C; + private const uint IdCodecId = 0x86; + private const uint TrackNumberVideo = 1; + private const uint TrackNumberAudio = 2; + private const byte VintMarkerOneByte = 0x80; + private const byte VintMarkerEightByte = 0x01; + + public ushort Width { get; set; } + public ushort Height { get; set; } + public float Framerate { get; set; } + public List FrameTable { get; set; } = []; + public byte[] FrameData { get; set; } = []; + public byte[]? VorbisCodecPrivate { get; set; } + public List VorbisAudioPackets { get; set; } = []; + public int VorbisSampleRate { get; set; } + public int VorbisChannels { get; set; } + + public byte[] WriteData() + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + writer.Write(BuildEbmlHeader()); + var idSegment = 0x18538067U; + WriteId(writer, idSegment); + // We use unknown size because total length is not known upfront + WriteUnknownSize(writer); + writer.Write(BuildInfo()); + writer.Write(BuildTracks()); + WriteClusters(writer); + return stream.ToArray(); + } + + private static byte[] BuildEbmlHeader() + { + var idEbml = 0x1A45DFA3U; + var idEbmlVersion = 0x4286U; + var idEbmlReadVersion = 0x42F7U; + var idEbmlMaxIdLength = 0x42F2U; + var idEbmlMaxSizeLength = 0x42F3U; + var idDocType = 0x4282U; + var idDocTypeVersion = 0x4287U; + var idDocTypeReadVersion = 0x4285U; + + var ebmlVersion = UintElement(idEbmlVersion, 1); + var ebmlReadVersion = UintElement(idEbmlReadVersion, 1); + var ebmlMaxIdLength = UintElement(idEbmlMaxIdLength, 4); + var ebmlMaxSizeLength = UintElement(idEbmlMaxSizeLength, 8); + var docType = StringElement(idDocType, "webm"); + var docTypeVersion = UintElement(idDocTypeVersion, 4); + var docTypeReadVersion = UintElement(idDocTypeReadVersion, 2); + + var content = Concat( + ebmlVersion, + ebmlReadVersion, + ebmlMaxIdLength, + ebmlMaxSizeLength, + docType, + docTypeVersion, + docTypeReadVersion + ); + + return Element(idEbml, content); + } + + private byte[] BuildInfo() + { + var idInfo = 0x1549A966U; + var idTimestampScale = 0x2AD7B1U; + var idMuxingApp = 0x4D80U; + var idWritingApp = 0x5741U; + var idDuration = 0x4489U; + var durationMs = FrameTable.Count / (double)Framerate * 1000.0; + var timestampScaleNanoseconds = 1_000_000UL; + + var timestampScale = UintElement(idTimestampScale, timestampScaleNanoseconds); + var muxingApp = StringElement(idMuxingApp, "AssetEditor"); + var writingApp = StringElement(idWritingApp, "AssetEditor"); + var duration = FloatElement(idDuration, durationMs); + + var content = Concat( + timestampScale, + muxingApp, + writingApp, + duration + ); + + return Element(idInfo, content); + } + + private byte[] BuildTracks() + { + var videoEntry = BuildVideoTrackEntry(); + + var hasVorbisAudio = VorbisCodecPrivate != null && VorbisAudioPackets.Count > 0; + if (!hasVorbisAudio) + return Element(IdTracks, videoEntry); + + var audioEntry = BuildAudioTrackEntry(); + return Element(IdTracks, Concat(videoEntry, audioEntry)); + } + + private byte[] BuildVideoTrackEntry() + { + var idVideo = 0xE0U; + var idPixelWidth = 0xB0U; + var idPixelHeight = 0xBAU; + + var trackNumber = UintElement(IdTrackNumber, TrackNumberVideo); + var trackUid = UintElement(IdTrackUid, TrackNumberVideo); + var trackType = UintElement(IdTrackType, 1); + var flagLacing = UintElement(IdFlagLacing, 0); + var codecId = StringElement(IdCodecId, "V_VP8"); + + var pixelWidth = UintElement(idPixelWidth, Width); + var pixelHeight = UintElement(idPixelHeight, Height); + var videoSettings = Element(idVideo, Concat(pixelWidth, pixelHeight)); + + var content = Concat( + trackNumber, + trackUid, + trackType, + flagLacing, + codecId, + videoSettings + ); + + return Element(IdTrackEntry, content); + } + + private byte[] BuildAudioTrackEntry() + { + var idCodecPrivate = 0x63A2U; + var idAudio = 0xE1U; + var idSamplingFrequency = 0xB5U; + var idChannels = 0x9FU; + + var trackNumber = UintElement(IdTrackNumber, TrackNumberAudio); + var trackUid = UintElement(IdTrackUid, TrackNumberAudio); + var trackType = UintElement(IdTrackType, 2); + var flagLacing = UintElement(IdFlagLacing, 0); + var codecId = StringElement(IdCodecId, "A_VORBIS"); + var codecPrivate = Element(idCodecPrivate, VorbisCodecPrivate!); + + var samplingFrequency = FloatElement(idSamplingFrequency, VorbisSampleRate); + var channels = UintElement(idChannels, (ulong)VorbisChannels); + var audioSettings = Element(idAudio, Concat(samplingFrequency, channels)); + + var content = Concat( + trackNumber, + trackUid, + trackType, + flagLacing, + codecId, + codecPrivate, + audioSettings + ); + + return Element(IdTrackEntry, content); + } + + private void WriteClusters(BinaryWriter writer) + { + if (FrameTable.Count == 0) + return; + + var idCluster = 0x1F43B675U; + var idTimestamp = 0xE7U; + var maxClusterDurationMs = 30000; + var msPerVideoFrame = 1000.0 / Framerate; + var videoDataOffset = 0; + var videoIndex = 0; + var audioIndex = 0; + + while (videoIndex < FrameTable.Count) + { + using var clusterContent = new MemoryStream(); + using var clusterWriter = new BinaryWriter(clusterContent); + + var clusterTimestampMs = (long)(videoIndex * msPerVideoFrame); + clusterWriter.Write(UintElement(idTimestamp, (ulong)clusterTimestampMs)); + + var blocks = new List(); + var clusterVideoStart = videoIndex; + + while (videoIndex < FrameTable.Count) + { + var frameTimestampMs = (long)(videoIndex * msPerVideoFrame); + var relativeMs = frameTimestampMs - clusterTimestampMs; + + var isKeyFrame = FrameTable[videoIndex].IsKeyFrame; + if (videoIndex > clusterVideoStart && isKeyFrame && relativeMs > maxClusterDurationMs) + break; + + var frame = FrameTable[videoIndex]; + var frameData = FrameData[videoDataOffset..(videoDataOffset + (int)frame.Size)]; + videoDataOffset += (int)frame.Size; + + blocks.Add(new ClusterBlock(frameTimestampMs, BuildSimpleBlock(frameData, (short)relativeMs, isKeyFrame, trackNumber: (int)TrackNumberVideo))); + videoIndex++; + } + + var clusterEndMs = long.MaxValue; + if (videoIndex < FrameTable.Count) + clusterEndMs = (long)(videoIndex * msPerVideoFrame); + + while (audioIndex < VorbisAudioPackets.Count && VorbisAudioPackets[audioIndex].TimestampMilliseconds < clusterEndMs) + { + var audioPacket = VorbisAudioPackets[audioIndex]; + var audioTimestampMs = audioPacket.TimestampMilliseconds; + var relativeMs = (short)Math.Clamp(audioTimestampMs - clusterTimestampMs, short.MinValue, short.MaxValue); + blocks.Add(new ClusterBlock(audioTimestampMs, BuildSimpleBlock(audioPacket.Data, relativeMs, isKeyFrame: false, trackNumber: (int)TrackNumberAudio))); + audioIndex++; + } + + foreach (var block in blocks.OrderBy(block => block.TimestampMs)) + clusterWriter.Write(block.Data); + + writer.Write(Element(idCluster, clusterContent.ToArray())); + } + } + + private static byte[] BuildSimpleBlock(byte[] frameData, short relativeTimestampMs, bool isKeyFrame, int trackNumber) + { + var idSimpleBlock = 0xA3U; + byte simpleBlockKeyframeFlag = 0x80; + byte simpleBlockNoFlags = 0x00; + + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + + writer.Write((byte)(VintMarkerOneByte | trackNumber)); + writer.Write((byte)((relativeTimestampMs >> 8) & 0xFF)); + writer.Write((byte)(relativeTimestampMs & 0xFF)); + + if (isKeyFrame) + writer.Write(simpleBlockKeyframeFlag); + else + writer.Write(simpleBlockNoFlags); + + writer.Write(frameData); + + return Element(idSimpleBlock, stream.ToArray()); + } + + private static byte[] Element(uint id, byte[] content) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + WriteId(writer, id); + WriteVint(writer, (ulong)content.Length); + writer.Write(content); + return stream.ToArray(); + } + + private static byte[] UintElement(uint id, ulong value) + { + var numBytes = 1; + var shifted = value >> 8; + while (shifted > 0) { shifted >>= 8; numBytes++; } + + var content = new byte[numBytes]; + var remaining = value; + for (var i = numBytes - 1; i >= 0; i--) + { + content[i] = (byte)(remaining & 0xFF); + remaining >>= 8; + } + return Element(id, content); + } + + private static byte[] StringElement(uint id, string value) => Element(id, Encoding.ASCII.GetBytes(value)); + + private static byte[] FloatElement(uint id, double value) + { + var bytes = BitConverter.GetBytes(value); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return Element(id, bytes); + } + + private static byte[] Concat(params byte[][] arrays) + { + var result = new byte[arrays.Sum(a => a.Length)]; + var offset = 0; + foreach (var array in arrays) + { + Buffer.BlockCopy(array, 0, result, offset, array.Length); + offset += array.Length; + } + return result; + } + + private static void WriteId(BinaryWriter writer, uint id) + { + var idMaxOneByte = 0xFFU; + var idMaxTwoByte = 0xFFFFU; + var idMaxThreeByte = 0xFFFFFFU; + + if (id <= idMaxOneByte) + writer.Write((byte)id); + else if (id <= idMaxTwoByte) + { + writer.Write((byte)(id >> 8)); + writer.Write((byte)(id & 0xFF)); + } + else if (id <= idMaxThreeByte) + { + writer.Write((byte)(id >> 16)); + writer.Write((byte)((id >> 8) & 0xFF)); + writer.Write((byte)(id & 0xFF)); + } + else + { + writer.Write((byte)(id >> 24)); + writer.Write((byte)((id >> 16) & 0xFF)); + writer.Write((byte)((id >> 8) & 0xFF)); + writer.Write((byte)(id & 0xFF)); + } + } + + private static void WriteVint(BinaryWriter writer, ulong value) + { + var vintMaxOneByte = 0x7EUL; + var vintMaxTwoByte = 0x3FFEUL; + var vintMaxThreeByte = 0x1FFFFEUL; + var vintMaxFourByte = 0x0FFFFFFEUL; + var vintMarkerTwoByte = 0x40UL; + var vintMarkerThreeByte = 0x20UL; + var vintMarkerFourByte = 0x10UL; + + if (value <= vintMaxOneByte) + writer.Write((byte)(VintMarkerOneByte | value)); + else if (value <= vintMaxTwoByte) + { + writer.Write((byte)(vintMarkerTwoByte | (value >> 8))); + writer.Write((byte)(value & 0xFF)); + } + else if (value <= vintMaxThreeByte) + { + writer.Write((byte)(vintMarkerThreeByte | (value >> 16))); + writer.Write((byte)((value >> 8) & 0xFF)); + writer.Write((byte)(value & 0xFF)); + } + else if (value <= vintMaxFourByte) + { + writer.Write((byte)(vintMarkerFourByte | (value >> 24))); + writer.Write((byte)((value >> 16) & 0xFF)); + writer.Write((byte)((value >> 8) & 0xFF)); + writer.Write((byte)(value & 0xFF)); + } + else + { + writer.Write(VintMarkerEightByte); + for (var i = 6; i >= 0; i--) + writer.Write((byte)((value >> (i * 8)) & 0xFF)); + } + } + + private static void WriteUnknownSize(BinaryWriter writer) + { + writer.Write(VintMarkerEightByte); + for (var i = 0; i < 7; i++) + writer.Write((byte)0xFF); + } + } +}