diff --git a/Codecs/Codec.ImaAdpcm/ImaAdpcmCodec.cs b/Codecs/Codec.ImaAdpcm/ImaAdpcmCodec.cs index 0d4420b41..64ab682a8 100644 --- a/Codecs/Codec.ImaAdpcm/ImaAdpcmCodec.cs +++ b/Codecs/Codec.ImaAdpcm/ImaAdpcmCodec.cs @@ -3,16 +3,8 @@ namespace Codec.ImaAdpcm; /// -/// IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) decoder. -/// Each 4-bit nibble encodes the magnitude + sign of the delta between samples; the -/// step size walks up and down a 89-entry log-spaced table based on the previous -/// nibble. Used by WAV format code 0x0011 with a block layout: -/// -/// Per-channel 4-byte header: int16 predictor, int8 step-index, 1 reserved byte. -/// For mono: remaining blockAlign - 4 bytes are nibble pairs (LSN first). -/// For stereo: headers are interleaved per channel (4 bytes L, 4 bytes R), then -/// nibbles are interleaved 4 bytes per channel (8 samples each). -/// +/// IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) codec. +/// Supports the Microsoft/Intel WAV block layout and Apple/QuickTime ima4 packets. /// public static class ImaAdpcmCodec { @@ -27,17 +19,18 @@ public static class ImaAdpcmCodec { private static readonly int[] IndexAdjust = [-1, -1, -1, -1, 2, 4, 6, 8]; + private const int WavHeaderBytes = 4; + private const int QuickTimePacketBytes = 34; + private const int QuickTimeSamplesPerPacket = 64; + /// /// Decodes IMA ADPCM data to one PCM buffer per channel. Each output buffer holds /// ((blockAlign/channels - 4) * 2 + 1) samples per block. /// public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int channels) { - if (channels is < 1 or > 2) - throw new ArgumentException("IMA ADPCM in WAV supports 1 or 2 channels.", nameof(channels)); - if (blockAlign < 4 * channels) - throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign)); + ValidateWavLayout(blockAlign, channels); - var samplesPerBlock = (blockAlign - 4 * channels) * 2 / channels + 1; + var samplesPerBlock = (blockAlign - WavHeaderBytes * channels) * 2 / channels + 1; var blockCount = adpcm.Length / blockAlign; var output = new short[channels][]; for (var c = 0; c < channels; ++c) output[c] = new short[blockCount * samplesPerBlock]; @@ -49,18 +42,15 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha var blockStart = b * blockAlign; var outStart = b * samplesPerBlock; - // Per-channel 4-byte headers at block start. for (var c = 0; c < channels; ++c) { - var h = blockStart + c * 4; + var h = blockStart + c * WavHeaderBytes; predictor[c] = BinaryPrimitives.ReadInt16LittleEndian(adpcm.Slice(h, 2)); - index[c] = adpcm[h + 2]; - if (index[c] > 88) index[c] = 88; - if (index[c] < 0) index[c] = 0; + index[c] = Math.Min((int)adpcm[h + 2], 88); output[c][outStart] = (short)predictor[c]; } - var dataStart = blockStart + 4 * channels; - var dataLen = blockAlign - 4 * channels; + var dataStart = blockStart + WavHeaderBytes * channels; + var dataLen = blockAlign - WavHeaderBytes * channels; if (channels == 1) { for (var i = 0; i < dataLen; ++i) { @@ -69,7 +59,6 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha output[0][outStart + 2 + i * 2] = DecodeNibble((byte)(byteVal >> 4), ref predictor[0], ref index[0]); } } else { - // Stereo: 4-byte groups alternate channels (L,R,L,R…). Each group yields 8 samples. var groups = dataLen / 8; for (var g = 0; g < groups; ++g) { for (var c = 0; c < 2; ++c) { @@ -87,43 +76,98 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha return output; } + /// + /// Encodes one or two equal-length PCM16 channel buffers to Microsoft/Intel IMA ADPCM WAV blocks. + /// The final block is padded with the last reconstructed sample so the raw coded stream remains + /// block-aligned; a container can retain the exact source sample count in its own metadata. + /// + public static byte[] Encode(IReadOnlyList pcm, int blockAlign) { + ArgumentNullException.ThrowIfNull(pcm); + var channels = pcm.Count; + ValidateWavLayout(blockAlign, channels); + if (pcm.Any(static c => c is null)) + throw new ArgumentException("PCM channel buffers cannot be null.", nameof(pcm)); + + var sampleCount = pcm[0].Length; + if (pcm.Any(c => c.Length != sampleCount)) + throw new ArgumentException("All PCM channel buffers must have the same sample count.", nameof(pcm)); + if (sampleCount == 0) + return []; + + var dataLen = blockAlign - WavHeaderBytes * channels; + var samplesPerBlock = dataLen * 2 / channels + 1; + var blockCount = (sampleCount + samplesPerBlock - 1) / samplesPerBlock; + var output = new byte[blockCount * blockAlign]; + + Span predictor = stackalloc int[channels]; + Span index = stackalloc int[channels]; + + for (var b = 0; b < blockCount; ++b) { + var blockStart = b * blockAlign; + var baseSample = b * samplesPerBlock; + + for (var c = 0; c < channels; ++c) { + var first = pcm[c][Math.Min(baseSample, sampleCount - 1)]; + var second = baseSample + 1 < sampleCount ? pcm[c][baseSample + 1] : first; + predictor[c] = first; + index[c] = StartIndexFor(Math.Abs(second - first)); + var h = blockStart + c * WavHeaderBytes; + BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(h, 2), first); + output[h + 2] = (byte)index[c]; + output[h + 3] = 0; + } + + var dataStart = blockStart + WavHeaderBytes * channels; + if (channels == 1) { + for (var i = 0; i < dataLen; ++i) { + var sampleIdx = baseSample + 1 + i * 2; + var low = EncodeNibble(Sample(pcm[0], sampleIdx, predictor[0]), ref predictor[0], ref index[0]); + var high = EncodeNibble(Sample(pcm[0], sampleIdx + 1, predictor[0]), ref predictor[0], ref index[0]); + output[dataStart + i] = (byte)((high << 4) | low); + } + } else { + var groups = dataLen / 8; + for (var g = 0; g < groups; ++g) { + for (var c = 0; c < 2; ++c) { + var gs = dataStart + g * 8 + c * 4; + for (var i = 0; i < 4; ++i) { + var sampleIdx = baseSample + 1 + g * 8 + i * 2; + var low = EncodeNibble(Sample(pcm[c], sampleIdx, predictor[c]), ref predictor[c], ref index[c]); + var high = EncodeNibble(Sample(pcm[c], sampleIdx + 1, predictor[c]), ref predictor[c], ref index[c]); + output[gs + i] = (byte)((high << 4) | low); + } + } + } + } + } + + return output; + } + /// /// Decodes the Apple/QuickTime ima4 packet variant (as carried by AIFC) into - /// one PCM buffer per channel. The data is a sequence of fixed 34-byte packets that - /// round-robin through the channels (ch0, ch1, …, ch0, …). Each packet is: - /// - /// a 2-byte big-endian preamble: the top 9 bits are the signed initial - /// predictor ((short)(preamble & 0xFF80)) and the low 7 bits are the - /// initial step index (clamped to ≤ 88); - /// 32 data bytes = 64 nibbles, low nibble first within each byte, decoded with - /// the standard IMA step tables. - /// - /// Every packet therefore yields exactly 64 samples for its channel. Unlike the WAV - /// block layout the packet does not emit the predictor itself as a sample. + /// one PCM buffer per channel. Packets are 34 bytes and round-robin through channels. /// public static short[][] DecodeQuickTime(ReadOnlySpan data, int channels) { if (channels < 1) throw new ArgumentException("QuickTime IMA ADPCM needs at least one channel.", nameof(channels)); - const int packetBytes = 34; - const int samplesPerPacket = 64; - var packetCount = data.Length / packetBytes; + var packetCount = data.Length / QuickTimePacketBytes; var packetsPerChannel = packetCount / channels; var output = new short[channels][]; for (var c = 0; c < channels; ++c) - output[c] = new short[packetsPerChannel * samplesPerPacket]; + output[c] = new short[packetsPerChannel * QuickTimeSamplesPerPacket]; for (var p = 0; p < packetsPerChannel * channels; ++p) { var channel = p % channels; - var packet = data.Slice(p * packetBytes, packetBytes); + var packet = data.Slice(p * QuickTimePacketBytes, QuickTimePacketBytes); var preamble = BinaryPrimitives.ReadUInt16BigEndian(packet); var predictor = (int)(short)(preamble & 0xFF80); - var index = preamble & 0x007F; - if (index > 88) index = 88; + var index = Math.Min(preamble & 0x007F, 88); - var outBase = (p / channels) * samplesPerPacket; + var outBase = (p / channels) * QuickTimeSamplesPerPacket; for (var i = 0; i < 32; ++i) { var byteVal = packet[2 + i]; output[channel][outBase + i * 2] = DecodeNibble((byte)(byteVal & 0x0F), ref predictor, ref index); @@ -134,6 +178,86 @@ public static short[][] DecodeQuickTime(ReadOnlySpan data, int channels) { return output; } + /// + /// Encodes equal-length PCM16 channel buffers to Apple/QuickTime ima4 packets. + /// A final partial packet is padded with the last reconstructed sample. Packets are emitted + /// in the channel-round-robin order used by AIFC/QuickTime. + /// + public static byte[] EncodeQuickTime(IReadOnlyList pcm) { + ArgumentNullException.ThrowIfNull(pcm); + var channels = pcm.Count; + if (channels < 1) + throw new ArgumentException("QuickTime IMA ADPCM needs at least one channel.", nameof(pcm)); + if (pcm.Any(static c => c is null)) + throw new ArgumentException("PCM channel buffers cannot be null.", nameof(pcm)); + + var sampleCount = pcm[0].Length; + if (pcm.Any(c => c.Length != sampleCount)) + throw new ArgumentException("All PCM channel buffers must have the same sample count.", nameof(pcm)); + if (sampleCount == 0) + return []; + + var packetsPerChannel = (sampleCount + QuickTimeSamplesPerPacket - 1) / QuickTimeSamplesPerPacket; + var output = new byte[packetsPerChannel * channels * QuickTimePacketBytes]; + + for (var packetIndex = 0; packetIndex < packetsPerChannel; ++packetIndex) { + var baseSample = packetIndex * QuickTimeSamplesPerPacket; + for (var c = 0; c < channels; ++c) { + var packetOffset = (packetIndex * channels + c) * QuickTimePacketBytes; + var first = pcm[c][Math.Min(baseSample, sampleCount - 1)]; + var predictor = (int)(short)(first & ~0x7F); + var second = baseSample + 1 < sampleCount ? pcm[c][baseSample + 1] : first; + var index = StartIndexFor(Math.Max(Math.Abs(first - predictor), Math.Abs(second - first))); + var preamble = (ushort)(((ushort)predictor & 0xFF80) | index); + BinaryPrimitives.WriteUInt16BigEndian(output.AsSpan(packetOffset, 2), preamble); + + for (var i = 0; i < 32; ++i) { + var sampleIdx = baseSample + i * 2; + var low = EncodeNibble(Sample(pcm[c], sampleIdx, predictor), ref predictor, ref index); + var high = EncodeNibble(Sample(pcm[c], sampleIdx + 1, predictor), ref predictor, ref index); + output[packetOffset + 2 + i] = (byte)((high << 4) | low); + } + } + } + + return output; + } + + private static void ValidateWavLayout(int blockAlign, int channels) { + if (channels is < 1 or > 2) + throw new ArgumentException("IMA ADPCM in WAV supports 1 or 2 channels.", nameof(channels)); + if (blockAlign < WavHeaderBytes * channels) + throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign)); + var dataLen = blockAlign - WavHeaderBytes * channels; + if (channels == 2 && dataLen % 8 != 0) + throw new ArgumentException("Stereo IMA ADPCM block data must contain complete 4-byte groups per channel.", nameof(blockAlign)); + } + + private static short Sample(short[] pcm, int index, int fallback) + => index < pcm.Length ? pcm[index] : (short)fallback; + + private static int StartIndexFor(int delta) { + var i = 0; + while (i < StepTable.Length - 1 && StepTable[i] < delta) + ++i; + return i; + } + + private static byte EncodeNibble(int sample, ref int predictor, ref int index) { + var step = StepTable[index]; + var delta = sample - predictor; + byte nibble = 0; + if (delta < 0) { + nibble = 8; + delta = -delta; + } + if (delta >= step) { nibble |= 4; delta -= step; } + if (delta >= step >> 1) { nibble |= 2; delta -= step >> 1; } + if (delta >= step >> 2) nibble |= 1; + DecodeNibble(nibble, ref predictor, ref index); + return nibble; + } + private static short DecodeNibble(byte nibble, ref int predictor, ref int index) { var step = StepTable[index]; var diff = step >> 3; @@ -142,11 +266,8 @@ private static short DecodeNibble(byte nibble, ref int predictor, ref int index) if ((nibble & 4) != 0) diff += step; if ((nibble & 8) != 0) predictor -= diff; else predictor += diff; - if (predictor > 32767) predictor = 32767; - else if (predictor < -32768) predictor = -32768; - index += IndexAdjust[nibble & 0x07]; - if (index < 0) index = 0; - else if (index > 88) index = 88; + predictor = Math.Clamp(predictor, short.MinValue, short.MaxValue); + index = Math.Clamp(index + IndexAdjust[nibble & 0x07], 0, 88); return (short)predictor; } } diff --git a/Codecs/Codec.Midi/MidiWriter.cs b/Codecs/Codec.Midi/MidiWriter.cs new file mode 100644 index 000000000..c441d11f6 --- /dev/null +++ b/Codecs/Codec.Midi/MidiWriter.cs @@ -0,0 +1,49 @@ +#pragma warning disable CS1591 +using System.Buffers.Binary; + +namespace Codec.Midi; + +/// Standard MIDI File emitter for already-encoded MTrk payloads. +public static class MidiWriter { + + /// + /// Builds an SMF from raw MTrk payloads. Format 0 requires exactly one track; + /// formats 1 and 2 may contain multiple tracks. Event bytes are preserved verbatim. + /// + public static byte[] BuildFile(IReadOnlyList trackBodies, int division, int format = 1) { + ArgumentNullException.ThrowIfNull(trackBodies); + if (format is < 0 or > 2) + throw new ArgumentOutOfRangeException(nameof(format), "SMF format must be 0, 1, or 2."); + if (trackBodies.Count == 0) + throw new ArgumentException("An SMF must contain at least one track.", nameof(trackBodies)); + if (format == 0 && trackBodies.Count != 1) + throw new ArgumentException("SMF format 0 requires exactly one track.", nameof(trackBodies)); + if (trackBodies.Count > ushort.MaxValue) + throw new ArgumentException("SMF track count exceeds the 16-bit header field.", nameof(trackBodies)); + if (division is < short.MinValue or > short.MaxValue) + throw new ArgumentOutOfRangeException(nameof(division)); + if (trackBodies.Any(static track => track is null)) + throw new ArgumentException("Track payloads cannot be null.", nameof(trackBodies)); + + using var output = new MemoryStream(); + output.Write("MThd"u8); + Span size = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(size, 6); + output.Write(size); + + Span header = stackalloc byte[6]; + BinaryPrimitives.WriteUInt16BigEndian(header, (ushort)format); + BinaryPrimitives.WriteUInt16BigEndian(header[2..], (ushort)trackBodies.Count); + BinaryPrimitives.WriteUInt16BigEndian(header[4..], unchecked((ushort)(short)division)); + output.Write(header); + + foreach (var track in trackBodies) { + output.Write("MTrk"u8); + BinaryPrimitives.WriteInt32BigEndian(size, track.Length); + output.Write(size); + output.Write(track); + } + + return output.ToArray(); + } +} diff --git a/Codecs/Codec.MsAdpcm/MsAdpcmCodec.cs b/Codecs/Codec.MsAdpcm/MsAdpcmCodec.cs index 22c55f760..502d04e72 100644 --- a/Codecs/Codec.MsAdpcm/MsAdpcmCodec.cs +++ b/Codecs/Codec.MsAdpcm/MsAdpcmCodec.cs @@ -3,16 +3,8 @@ namespace Codec.MsAdpcm; /// -/// Microsoft ADPCM decoder (WAV format code 0x0002). Block layout: -/// -/// Per channel: 1-byte predictor selector (index into the 7-entry coefficient table), -/// 2-byte delta (quantization step), 2-byte sample1, 2-byte sample2. -/// Followed by blockAlign - 7*channels bytes of ADPCM nibbles, where each -/// byte packs two samples (high nibble first) that alternate channels when stereo. -/// -/// Predictor coefficients are taken from a standard 7-entry table; the new sample is -/// computed as (s1 * c1 + s2 * c2) >> 8 plus a dequantized delta, with the delta -/// itself adapting based on an error table. +/// Microsoft ADPCM codec (WAV format code 0x0002). Uses the canonical seven adaptive +/// predictor pairs and the Microsoft 4-bit delta adaptation table. /// public static class MsAdpcmCodec { @@ -29,12 +21,8 @@ public static class MsAdpcmCodec { /// 2 + (blockAlign - 7*channels) * 2 / channels samples per channel. /// public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int channels) { - if (channels is < 1 or > 2) - throw new ArgumentException("MS ADPCM supports 1 or 2 channels.", nameof(channels)); + ValidateLayout(blockAlign, channels); var headerBytes = 7 * channels; - if (blockAlign < headerBytes) - throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign)); - var samplesPerBlock = 2 + (blockAlign - headerBytes) * 2 / channels; var blockCount = adpcm.Length / blockAlign; var output = new short[channels][]; @@ -51,11 +39,11 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha var p = blockStart; for (var c = 0; c < channels; ++c) { - predIndex[c] = adpcm[p++]; - if (predIndex[c] > 6) predIndex[c] = 6; + predIndex[c] = Math.Min((int)adpcm[p++], 6); } for (var c = 0; c < channels; ++c) { delta[c] = BinaryPrimitives.ReadInt16LittleEndian(adpcm.Slice(p, 2)); + if (delta[c] < 16) delta[c] = 16; p += 2; } for (var c = 0; c < channels; ++c) { @@ -67,7 +55,6 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha p += 2; } - // First two samples are sample2 then sample1 (reverse storage). for (var c = 0; c < channels; ++c) { output[c][outStart] = (short)sample2[c]; output[c][outStart + 1] = (short)sample1[c]; @@ -77,18 +64,15 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha var sampleIdx = outStart + 2; for (var i = 0; i < dataLen; ++i) { var byteVal = adpcm[p + i]; - // High nibble first → first channel for stereo; low nibble second. - var n1 = (byteVal >> 4) & 0x0F; - var n2 = byteVal & 0x0F; + var n1 = (byte)((byteVal >> 4) & 0x0F); + var n2 = (byte)(byteVal & 0x0F); if (channels == 1) { - output[0][sampleIdx++] = DecodeNibble(n1, ref predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); - output[0][sampleIdx++] = DecodeNibble(n2, ref predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); + output[0][sampleIdx++] = DecodeNibble(n1, predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); + output[0][sampleIdx++] = DecodeNibble(n2, predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); } else { - var leftOut = DecodeNibble(n1, ref predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); - var rightOut = DecodeNibble(n2, ref predIndex[1], ref delta[1], ref sample1[1], ref sample2[1]); - output[0][sampleIdx] = leftOut; - output[1][sampleIdx] = rightOut; + output[0][sampleIdx] = DecodeNibble(n1, predIndex[0], ref delta[0], ref sample1[0], ref sample2[0]); + output[1][sampleIdx] = DecodeNibble(n2, predIndex[1], ref delta[1], ref sample1[1], ref sample2[1]); ++sampleIdx; } } @@ -96,17 +80,148 @@ public static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int cha return output; } - private static short DecodeNibble(int nibble, ref int predIndex, ref int delta, ref int sample1, ref int sample2) { - // Sign-extend 4-bit nibble. + /// + /// Encodes one or two equal-length PCM16 channel buffers to Microsoft ADPCM WAV blocks. + /// For each block the encoder searches all seven standard predictor pairs and a logarithmic + /// set of legal starting deltas, retaining the combination with the lowest reconstruction + /// error. The final block is padded with the last reconstructed sample. + /// + public static byte[] Encode(IReadOnlyList pcm, int blockAlign) { + ArgumentNullException.ThrowIfNull(pcm); + var channels = pcm.Count; + ValidateLayout(blockAlign, channels); + if (pcm.Any(static c => c is null)) + throw new ArgumentException("PCM channel buffers cannot be null.", nameof(pcm)); + + var sampleCount = pcm[0].Length; + if (pcm.Any(c => c.Length != sampleCount)) + throw new ArgumentException("All PCM channel buffers must have the same sample count.", nameof(pcm)); + if (sampleCount == 0) + return []; + + var headerBytes = 7 * channels; + var dataLen = blockAlign - headerBytes; + var samplesPerBlock = 2 + dataLen * 2 / channels; + var nibblesPerChannel = samplesPerBlock - 2; + var blockCount = (sampleCount + samplesPerBlock - 1) / samplesPerBlock; + var output = new byte[blockCount * blockAlign]; + + for (var b = 0; b < blockCount; ++b) { + var baseSample = b * samplesPerBlock; + var predictorIndex = new int[channels]; + var initialDelta = new int[channels]; + var sample1 = new short[channels]; + var sample2 = new short[channels]; + var channelNibbles = new byte[channels][]; + + for (var c = 0; c < channels; ++c) { + sample2[c] = pcm[c][Math.Min(baseSample, sampleCount - 1)]; + sample1[c] = baseSample + 1 < sampleCount ? pcm[c][baseSample + 1] : sample2[c]; + (predictorIndex[c], initialDelta[c], channelNibbles[c]) = SelectBlockEncoding( + pcm[c], baseSample, nibblesPerChannel, sample1[c], sample2[c]); + } + + var p = b * blockAlign; + for (var c = 0; c < channels; ++c) + output[p++] = (byte)predictorIndex[c]; + for (var c = 0; c < channels; ++c) { + BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(p, 2), (short)initialDelta[c]); + p += 2; + } + for (var c = 0; c < channels; ++c) { + BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(p, 2), sample1[c]); + p += 2; + } + for (var c = 0; c < channels; ++c) { + BinaryPrimitives.WriteInt16LittleEndian(output.AsSpan(p, 2), sample2[c]); + p += 2; + } + + if (channels == 1) { + for (var i = 0; i < dataLen; ++i) + output[p + i] = (byte)((channelNibbles[0][i * 2] << 4) | channelNibbles[0][i * 2 + 1]); + } else { + for (var i = 0; i < dataLen; ++i) + output[p + i] = (byte)((channelNibbles[0][i] << 4) | channelNibbles[1][i]); + } + } + + return output; + } + + private static (int Predictor, int Delta, byte[] Nibbles) SelectBlockEncoding( + short[] pcm, int baseSample, int nibbleCount, short firstHistory, short secondHistory) { + var bestError = long.MaxValue; + var bestPredictor = 0; + var bestDelta = 16; + byte[] bestNibbles = new byte[nibbleCount]; + + Span deltaCandidates = stackalloc int[13]; + var candidateCount = 0; + for (var d = 16; d <= 32767 && candidateCount < deltaCandidates.Length; d <<= 1) + deltaCandidates[candidateCount++] = d; + deltaCandidates[candidateCount - 1] = 32767; + + for (var predictor = 0; predictor < AdaptCoeff1.Length; ++predictor) { + for (var candidate = 0; candidate < candidateCount; ++candidate) { + var delta = deltaCandidates[candidate]; + var s1 = (int)firstHistory; + var s2 = (int)secondHistory; + var nibbles = new byte[nibbleCount]; + long error = 0; + + for (var i = 0; i < nibbleCount; ++i) { + var sourceIndex = baseSample + 2 + i; + var target = sourceIndex < pcm.Length ? pcm[sourceIndex] : (short)s1; + var nibble = EncodeNibble(target, predictor, ref delta, ref s1, ref s2); + nibbles[i] = nibble; + var diff = target - s1; + error += (long)diff * diff; + if (error >= bestError) + break; + } + + if (error >= bestError) + continue; + bestError = error; + bestPredictor = predictor; + bestDelta = deltaCandidates[candidate]; + bestNibbles = nibbles; + } + } + + return (bestPredictor, bestDelta, bestNibbles); + } + + private static byte EncodeNibble(int sample, int predIndex, ref int delta, ref int sample1, ref int sample2) { + var predicted = (sample1 * AdaptCoeff1[predIndex] + sample2 * AdaptCoeff2[predIndex]) >> 8; + var residual = sample - predicted; + var quantized = residual >= 0 + ? (residual + delta / 2) / delta + : -((-residual + delta / 2) / delta); + quantized = Math.Clamp(quantized, -8, 7); + var nibble = (byte)(quantized & 0x0F); + DecodeNibble(nibble, predIndex, ref delta, ref sample1, ref sample2); + return nibble; + } + + private static short DecodeNibble(byte nibble, int predIndex, ref int delta, ref int sample1, ref int sample2) { var signed = nibble < 8 ? nibble : nibble - 16; var predicted = (sample1 * AdaptCoeff1[predIndex] + sample2 * AdaptCoeff2[predIndex]) >> 8; predicted += signed * delta; - if (predicted > 32767) predicted = 32767; - else if (predicted < -32768) predicted = -32768; + predicted = Math.Clamp(predicted, short.MinValue, short.MaxValue); sample2 = sample1; sample1 = predicted; delta = AdaptationTable[nibble] * delta >> 8; if (delta < 16) delta = 16; return (short)predicted; } + + private static void ValidateLayout(int blockAlign, int channels) { + if (channels is < 1 or > 2) + throw new ArgumentException("MS ADPCM supports 1 or 2 channels.", nameof(channels)); + var headerBytes = 7 * channels; + if (blockAlign < headerBytes) + throw new ArgumentException($"blockAlign {blockAlign} too small for {channels} channel(s).", nameof(blockAlign)); + } } diff --git a/Codecs/Codec.WsAdpcm/WsAdpcmCodec.cs b/Codecs/Codec.WsAdpcm/WsAdpcmCodec.cs index 68450750d..1c9eb5c66 100644 --- a/Codecs/Codec.WsAdpcm/WsAdpcmCodec.cs +++ b/Codecs/Codec.WsAdpcm/WsAdpcmCodec.cs @@ -5,57 +5,29 @@ namespace Codec.WsAdpcm; /// /// Westwood Studios "WS" ADPCM (a.k.a. SND1), the compression carried by Westwood -/// .aud streams in Command & Conquer-era games. The codec works entirely in -/// the 8-bit unsigned sample domain; callers convert the bytes to 16-bit PCM as -/// (sample - 128) << 8. -/// -/// A WS stream is a sequence of chunks. The container supplies, per chunk, the number -/// of compressed input bytes and the number of decompressed output bytes. When the two -/// are equal the chunk is a raw 8-bit copy; otherwise it is a stream of commands. Each -/// command byte's top two bits select a mode and its low six bits carry a count: -/// -/// 0 — four 2-bit deltas follow packed in one byte, scaled by -/// 2 << shift where shift = count, each looked up in the 4-entry -/// table {-2,-1,0,1}; -/// 1(count + 1) bytes follow, each holding two 4-bit deltas -/// (low nibble first), scaled by 2 << shift via the 16-entry WS table; -/// 2 — if bit 5 of the byte is set the low five bits are a signed delta -/// applied to the current sample; otherwise (count + 1) raw bytes follow and -/// are copied verbatim; -/// 3 — repeat (hold) the current sample (count + 1) times. -/// -/// The running sample is clamped to [0, 255] after every update. -/// -/// This implementation is decode-only; Westwood .aud files are authored through -/// the IMA path instead (see FileFormat.Aud). +/// .aud streams in Command & Conquer-era games. The codec operates in the +/// unsigned 8-bit sample domain and supports both command-coded and raw chunks. /// public static class WsAdpcmCodec { - // 2-bit delta table (mode 0). private static readonly int[] WsTable2Bit = [-2, -1, 0, 1]; - - // 4-bit delta table (mode 1). private static readonly int[] WsTable4Bit = [-9, -8, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 8]; /// /// Decodes one WS-ADPCM chunk payload into bytes of - /// 8-bit unsigned PCM. is the compressed body only - /// (the container's per-chunk size header is already consumed). When the payload - /// length equals the chunk is a verbatim 8-bit copy. + /// 8-bit unsigned PCM. When payload length equals output length the chunk is raw. /// public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { if (expectedOut < 0) throw new ArgumentOutOfRangeException(nameof(expectedOut)); var output = new byte[expectedOut]; - - // Raw chunk: a straight copy, no command decoding. if (chunkPayload.Length == expectedOut) { chunkPayload.CopyTo(output); return output; } - var sample = 0x80; // WS streams start centred at 128 (silence). + var sample = 0x80; var inPos = 0; var outPos = 0; @@ -66,7 +38,6 @@ public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { switch (mode) { case 0: { - // count = shift for the 2-bit deltas; one following byte packs four deltas. if (inPos >= chunkPayload.Length) break; var packed = chunkPayload[inPos++]; for (var i = 0; i < 4 && outPos < expectedOut; ++i) { @@ -77,7 +48,6 @@ public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { break; } case 1: { - // (count + 1) bytes, each two 4-bit deltas (low nibble first). var bytes = count + 1; for (var b = 0; b < bytes && inPos < chunkPayload.Length && outPos < expectedOut; ++b) { var packed = chunkPayload[inPos++]; @@ -91,13 +61,11 @@ public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { } case 2: { if ((command & 0x20) != 0) { - // Small signed delta carried in the low five bits (sign-extended). - var delta = (command & 0x1F); + var delta = command & 0x1F; if ((delta & 0x10) != 0) delta -= 0x20; sample = Clamp(sample + delta); output[outPos++] = (byte)sample; } else { - // (count + 1) raw bytes copied verbatim; the last becomes the new sample. var bytes = count + 1; for (var b = 0; b < bytes && inPos < chunkPayload.Length && outPos < expectedOut; ++b) { sample = chunkPayload[inPos++]; @@ -106,7 +74,7 @@ public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { } break; } - default: { // mode 3: hold the current sample (count + 1) times. + default: { var repeats = count + 1; for (var r = 0; r < repeats && outPos < expectedOut; ++r) output[outPos++] = (byte)sample; @@ -118,6 +86,71 @@ public static byte[] Decode(ReadOnlySpan chunkPayload, int expectedOut) { return output; } + /// + /// Encodes unsigned 8-bit PCM as a lossless WS chunk. The writer uses hold commands + /// for repeated predictor values, one-byte signed-delta commands for changes in + /// [-16,+15], and literal runs for everything else. If commands do not beat the raw + /// representation, the raw bytes are returned because raw chunks are part of the format. + /// + public static byte[] Encode(ReadOnlySpan pcmU8) { + if (pcmU8.IsEmpty) + return []; + + using var encoded = new MemoryStream(pcmU8.Length); + var sample = 0x80; + var pos = 0; + + while (pos < pcmU8.Length) { + if (pcmU8[pos] == sample) { + var run = 1; + while (run < 64 && pos + run < pcmU8.Length && pcmU8[pos + run] == sample) + ++run; + encoded.WriteByte((byte)(0xC0 | (run - 1))); + pos += run; + continue; + } + + var delta = pcmU8[pos] - sample; + if (delta is >= -16 and <= 15) { + encoded.WriteByte((byte)(0xA0 | (delta & 0x1F))); + sample = pcmU8[pos++]; + continue; + } + + var start = pos; + var count = 0; + while (count < 32 && pos < pcmU8.Length) { + // Stop before a value that can be represented by a cheaper one-byte command + // relative to the last literal in this run (or the incoming predictor). + var predecessor = count == 0 ? sample : pcmU8[pos - 1]; + var nextDelta = pcmU8[pos] - predecessor; + if (count > 0 && (pcmU8[pos] == predecessor || nextDelta is >= -16 and <= 15)) + break; + ++count; + ++pos; + } + if (count == 0) { + count = 1; + ++pos; + } + + encoded.WriteByte((byte)(0x80 | (count - 1))); + encoded.Write(pcmU8.Slice(start, count)); + sample = pcmU8[start + count - 1]; + } + + var commands = encoded.ToArray(); + return commands.Length < pcmU8.Length ? commands : pcmU8.ToArray(); + } + + /// Encodes signed PCM16 after reducing it to WS's native unsigned-8 domain. + public static byte[] EncodePcm16(ReadOnlySpan pcm16) { + var pcmU8 = new byte[pcm16.Length]; + for (var i = 0; i < pcm16.Length; ++i) + pcmU8[i] = (byte)((pcm16[i] >> 8) + 128); + return Encode(pcmU8); + } + /// /// Converts a buffer of decoded 8-bit unsigned WS samples to signed 16-bit PCM via /// (sample - 128) << 8. diff --git a/Compression.Tests/Audio/AdpcmEncodeTests.cs b/Compression.Tests/Audio/AdpcmEncodeTests.cs new file mode 100644 index 000000000..01249373e --- /dev/null +++ b/Compression.Tests/Audio/AdpcmEncodeTests.cs @@ -0,0 +1,130 @@ +#pragma warning disable CS1591 +using Codec.ImaAdpcm; +using Codec.MsAdpcm; + +namespace Compression.Tests.Audio; + +[TestFixture] +public class AdpcmEncodeTests { + + [Test] + public void ImaAdpcm_EncodeDecode_MonoSine_StaysWithinTolerance() { + const int blockAlign = 256; + const int samplesPerBlock = 505; + var pcm = new short[samplesPerBlock * 6]; + for (var i = 0; i < pcm.Length; ++i) + pcm[i] = (short)(Math.Sin(i * 2 * Math.PI / 53) * 12000); + + var encoded = ImaAdpcmCodec.Encode([pcm], blockAlign); + var decoded = ImaAdpcmCodec.Decode(encoded, blockAlign, channels: 1)[0]; + + Assert.That(encoded.Length, Is.EqualTo(blockAlign * 6)); + Assert.That(MaxError(pcm, decoded), Is.LessThan(2500)); + Assert.That(ImaAdpcmCodec.Encode([pcm], blockAlign), Is.EqualTo(encoded), "encoder must be deterministic"); + } + + [Test] + public void ImaAdpcm_EncodeDecode_Stereo_UsesMicrosoftGroupLayout() { + const int blockAlign = 512; + const int samplesPerBlock = 505; + var left = new short[samplesPerBlock * 4]; + var right = new short[left.Length]; + for (var i = 0; i < left.Length; ++i) { + left[i] = (short)(Math.Sin(i * 2 * Math.PI / 47) * 10000); + right[i] = (short)(Math.Cos(i * 2 * Math.PI / 61) * 9000); + } + + var encoded = ImaAdpcmCodec.Encode([left, right], blockAlign); + var decoded = ImaAdpcmCodec.Decode(encoded, blockAlign, channels: 2); + + Assert.That(MaxError(left, decoded[0]), Is.LessThan(2500)); + Assert.That(MaxError(right, decoded[1]), Is.LessThan(2500)); + } + + [Test] + public void ImaAdpcm_EncodeQuickTime_RoundTripsPacketsAndChannels() { + const int samplesPerPacket = 64; + var left = new short[samplesPerPacket * 5]; + var right = new short[left.Length]; + for (var i = 0; i < left.Length; ++i) { + left[i] = (short)(Math.Sin(i * 2 * Math.PI / 37) * 7000); + right[i] = (short)(Math.Cos(i * 2 * Math.PI / 43) * 6000); + } + + var encoded = ImaAdpcmCodec.EncodeQuickTime([left, right]); + var decoded = ImaAdpcmCodec.DecodeQuickTime(encoded, channels: 2); + + Assert.That(encoded.Length, Is.EqualTo(34 * 2 * 5)); + Assert.That(MaxError(left, decoded[0]), Is.LessThan(3000)); + Assert.That(MaxError(right, decoded[1]), Is.LessThan(3000)); + } + + [Test] + public void ImaAdpcm_Encode_PadsOnlyTheTerminalBlock() { + const int blockAlign = 256; + var pcm = new short[506]; + for (var i = 0; i < pcm.Length; ++i) pcm[i] = (short)(i * 10 - 2000); + + var encoded = ImaAdpcmCodec.Encode([pcm], blockAlign); + var decoded = ImaAdpcmCodec.Decode(encoded, blockAlign, channels: 1)[0]; + + Assert.That(encoded.Length, Is.EqualTo(blockAlign * 2)); + Assert.That(decoded.Length, Is.EqualTo(505 * 2)); + Assert.That(MaxError(pcm, decoded), Is.LessThan(2500)); + } + + [Test] + public void MsAdpcm_EncodeDecode_MonoSine_StaysWithinTolerance() { + const int blockAlign = 256; + const int samplesPerBlock = 500; + var pcm = new short[samplesPerBlock * 5]; + for (var i = 0; i < pcm.Length; ++i) + pcm[i] = (short)(Math.Sin(i * 2 * Math.PI / 59) * 11000); + + var encoded = MsAdpcmCodec.Encode([pcm], blockAlign); + var decoded = MsAdpcmCodec.Decode(encoded, blockAlign, channels: 1)[0]; + + Assert.That(encoded.Length, Is.EqualTo(blockAlign * 5)); + Assert.That(decoded[0], Is.EqualTo(pcm[0])); + Assert.That(decoded[1], Is.EqualTo(pcm[1])); + Assert.That(MaxError(pcm, decoded), Is.LessThan(3500)); + Assert.That(MsAdpcmCodec.Encode([pcm], blockAlign), Is.EqualTo(encoded), "encoder must be deterministic"); + } + + [Test] + public void MsAdpcm_EncodeDecode_Stereo_InterleavesNibbles() { + const int blockAlign = 512; + const int samplesPerBlock = 500; + var left = new short[samplesPerBlock * 4]; + var right = new short[left.Length]; + for (var i = 0; i < left.Length; ++i) { + left[i] = (short)(Math.Sin(i * 2 * Math.PI / 41) * 9000); + right[i] = (short)(Math.Cos(i * 2 * Math.PI / 67) * 8000); + } + + var encoded = MsAdpcmCodec.Encode([left, right], blockAlign); + var decoded = MsAdpcmCodec.Decode(encoded, blockAlign, channels: 2); + + Assert.That(decoded[0][0], Is.EqualTo(left[0])); + Assert.That(decoded[0][1], Is.EqualTo(left[1])); + Assert.That(decoded[1][0], Is.EqualTo(right[0])); + Assert.That(decoded[1][1], Is.EqualTo(right[1])); + Assert.That(MaxError(left, decoded[0]), Is.LessThan(3500)); + Assert.That(MaxError(right, decoded[1]), Is.LessThan(3500)); + } + + [Test] + public void AdpcmEncoders_EmptyInput_ReturnsEmpty() { + Assert.That(ImaAdpcmCodec.Encode([Array.Empty()], 256), Is.Empty); + Assert.That(ImaAdpcmCodec.EncodeQuickTime([Array.Empty()]), Is.Empty); + Assert.That(MsAdpcmCodec.Encode([Array.Empty()], 256), Is.Empty); + } + + private static int MaxError(ReadOnlySpan expected, ReadOnlySpan actual) { + Assert.That(actual.Length, Is.GreaterThanOrEqualTo(expected.Length)); + var result = 0; + for (var i = 0; i < expected.Length; ++i) + result = Math.Max(result, Math.Abs(actual[i] - expected[i])); + return result; + } +} diff --git a/Compression.Tests/Bonk/BonkFormatWriteTests.cs b/Compression.Tests/Bonk/BonkFormatWriteTests.cs new file mode 100644 index 000000000..b03044bdf --- /dev/null +++ b/Compression.Tests/Bonk/BonkFormatWriteTests.cs @@ -0,0 +1,59 @@ +#pragma warning disable CS1591 +using Codec.Bonk; +using Codec.Pcm; +using Compression.Registry; +using FileFormat.Bonk; + +namespace Compression.Tests.Bonk; + +[TestFixture] +public class BonkFormatWriteTests { + + [Test] + public void Create_PassesThroughFullBonk() { + var pcm = MakeInterleavedStereoPcm(256); + var bonk = BonkCodec.Compress(pcm, channels: 2, sampleRate: 44100); + var inputs = new[] { ArchiveInputInfo.InMemory("FULL.bonk", bonk) }; + using var output = new MemoryStream(); + + new BonkFormatDescriptor().Create(output, inputs, new FormatCreateOptions()); + + Assert.That(output.ToArray(), Is.EqualTo(bonk)); + } + + [Test] + public void Create_AssemblesStereoChannelWavs_Losslessly() { + const int frames = 512; + var left = new byte[frames * 2]; + var right = new byte[frames * 2]; + for (var i = 0; i < frames; ++i) { + var l = (short)(Math.Sin(i * 2 * Math.PI / 43) * 12000); + var r = (short)(Math.Cos(i * 2 * Math.PI / 67) * 9000); + System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(left.AsSpan(i * 2), l); + System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(right.AsSpan(i * 2), r); + } + + var inputs = new[] { + ArchiveInputInfo.InMemory("LEFT.wav", PcmCodec.ToWavBlob(left, 1, 44100, 16, formatCode: 1)), + ArchiveInputInfo.InMemory("RIGHT.wav", PcmCodec.ToWavBlob(right, 1, 44100, 16, formatCode: 1)), + }; + + using var output = new MemoryStream(); + var descriptor = new BonkFormatDescriptor(); + descriptor.Create(output, inputs, new FormatCreateOptions()); + + var decoded = BonkCodec.Decompress(output.ToArray()); + Assert.That(decoded, Is.EqualTo(PcmCodec.Interleave([left, right], 16))); + Assert.That(descriptor.Capabilities.HasFlag(FormatCapabilities.CanCreate), Is.True); + } + + private static byte[] MakeInterleavedStereoPcm(int frames) { + var left = new byte[frames * 2]; + var right = new byte[frames * 2]; + for (var i = 0; i < frames; ++i) { + System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(left.AsSpan(i * 2), (short)(i * 23 - 2000)); + System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(right.AsSpan(i * 2), (short)(2000 - i * 17)); + } + return PcmCodec.Interleave([left, right], 16); + } +} diff --git a/Compression.Tests/Midi/MidiWriteTests.cs b/Compression.Tests/Midi/MidiWriteTests.cs new file mode 100644 index 000000000..a744e4d73 --- /dev/null +++ b/Compression.Tests/Midi/MidiWriteTests.cs @@ -0,0 +1,82 @@ +#pragma warning disable CS1591 +using Codec.Midi; +using Compression.Registry; +using FileFormat.Midi; + +namespace Compression.Tests.Midi; + +[TestFixture] +public class MidiWriteTests { + + [Test] + public void MidiWriter_MultiTrack_PreservesBodiesAndHeader() { + byte[] conductor = [0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20, 0x00, 0xFF, 0x2F, 0x00]; + byte[] notes = [0x00, 0x90, 0x3C, 0x64, 0x60, 0x80, 0x3C, 0x00, 0x00, 0xFF, 0x2F, 0x00]; + + var blob = MidiWriter.BuildFile([conductor, notes], division: 96, format: 1); + var codec = new MidiCodec(); + var header = codec.ReadHeader(blob); + var tracks = codec.FindTracks(blob); + + Assert.That(header.Format, Is.EqualTo(1)); + Assert.That(header.NumTracks, Is.EqualTo(2)); + Assert.That(header.Division, Is.EqualTo(96)); + Assert.That(codec.ExtractTrackBytes(blob, tracks[0]), Is.EqualTo(conductor)); + Assert.That(codec.ExtractTrackBytes(blob, tracks[1]), Is.EqualTo(notes)); + } + + [Test] + public void Descriptor_Create_ReassemblesExtractedTracksByteExactly() { + byte[] first = [0x00, 0xFF, 0x03, 0x01, (byte)'A', 0x00, 0xFF, 0x2F, 0x00]; + byte[] second = [0x00, 0xFF, 0x03, 0x01, (byte)'B', 0x00, 0xFF, 0x2F, 0x00]; + var source = MidiWriter.BuildFile([first, second], division: 480, format: 1); + var descriptor = new MidiFormatDescriptor(); + + var inputs = new List(); + foreach (var name in new[] { "track_00_A.mid", "track_01_B.mid" }) { + using var input = new MemoryStream(source); + using var extracted = new MemoryStream(); + descriptor.ExtractEntry(input, name, extracted, null); + inputs.Add(ArchiveInputInfo.InMemory(name, extracted.ToArray())); + } + + using var output = new MemoryStream(); + descriptor.Create(output, inputs, new FormatCreateOptions()); + var rebuilt = output.ToArray(); + + var codec = new MidiCodec(); + var header = codec.ReadHeader(rebuilt); + var tracks = codec.FindTracks(rebuilt); + Assert.That(header.Format, Is.EqualTo(1)); + Assert.That(header.NumTracks, Is.EqualTo(2)); + Assert.That(header.Division, Is.EqualTo(480)); + Assert.That(codec.ExtractTrackBytes(rebuilt, tracks[0]), Is.EqualTo(first)); + Assert.That(codec.ExtractTrackBytes(rebuilt, tracks[1]), Is.EqualTo(second)); + Assert.That(descriptor.Capabilities.HasFlag(FormatCapabilities.CanCreate), Is.True); + } + + [Test] + public void Descriptor_Create_RejectsMismatchedTrackDivisions() { + byte[] eot = [0x00, 0xFF, 0x2F, 0x00]; + var inputs = new[] { + ArchiveInputInfo.InMemory("track_00_a.mid", MidiWriter.BuildFile([eot], division: 96, format: 0)), + ArchiveInputInfo.InMemory("track_01_b.mid", MidiWriter.BuildFile([eot], division: 120, format: 0)), + }; + + using var output = new MemoryStream(); + Assert.Throws(() => + new MidiFormatDescriptor().Create(output, inputs, new FormatCreateOptions())); + } + + [Test] + public void Descriptor_Create_FullMidi_PassesThroughAfterValidation() { + byte[] eot = [0x00, 0xFF, 0x2F, 0x00]; + var source = MidiWriter.BuildFile([eot], division: -6360, format: 0); // SMPTE-form division + using var output = new MemoryStream(); + + new MidiFormatDescriptor().Create(output, + [ArchiveInputInfo.InMemory("FULL.mid", source)], new FormatCreateOptions()); + + Assert.That(output.ToArray(), Is.EqualTo(source)); + } +} diff --git a/FileFormats/FileFormat.Bonk/BonkFormatDescriptor.cs b/FileFormats/FileFormat.Bonk/BonkFormatDescriptor.cs index a2fb92ad1..10b1ceee8 100644 --- a/FileFormats/FileFormat.Bonk/BonkFormatDescriptor.cs +++ b/FileFormats/FileFormat.Bonk/BonkFormatDescriptor.cs @@ -3,34 +3,29 @@ using Codec.Bonk; using Codec.Pcm; using Compression.Registry; +using FileFormat.Wav; namespace FileFormat.Bonk; /// -/// Exposes a Bonk (.bonk) file as a pseudo-archive of FULL.bonk (Kind -/// Container) plus, when the bitstream decodes, one mono WAV per channel -/// (Kind Channel, named via ) and a -/// metadata.ini (Kind Tag). Decode failures degrade gracefully to a -/// FULL-only listing. A Bonk file carries a length-prefixed original filename before -/// its '\0BONK' tag, so the tag is rarely at offset 0; detection therefore -/// leans on the .bonk extension plus a low-confidence offset-0 tag match, -/// while listing scans for the tag wherever it sits. +/// Exposes a Bonk (.bonk) file as a pseudo-archive of FULL.bonk plus, +/// when the bitstream decodes, one mono WAV per channel and a metadata.ini tag. +/// The descriptor is creatable (WORM): it passes through FULL.bonk or assembles +/// a new lossless Bonk stream from one or two mono PCM16 WAV channel files. /// public sealed class BonkFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, - IArchiveInMemoryExtract { + IArchiveInMemoryExtract, IArchiveWriteConstraints, IArchiveCreatable { public string Id => "Bonk"; public string DisplayName => "Bonk Audio"; public FormatCategory Category => FormatCategory.Audio; public FormatCapabilities Capabilities => FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest | - FormatCapabilities.SupportsMultipleEntries; + FormatCapabilities.CanCreate | FormatCapabilities.SupportsMultipleEntries; public string DefaultExtension => ".bonk"; public IReadOnlyList Extensions => [".bonk"]; public IReadOnlyList CompoundExtensions => []; - // The '\0BONK' tag follows a variable-length filename, so a fixed-offset magic only - // matches the (rare) tag-at-offset-0 case; keep it low confidence to avoid clashes. public IReadOnlyList MagicSignatures => [new([0x00, (byte)'B', (byte)'O', (byte)'N', (byte)'K'], Offset: 0, Confidence: 0.30)]; public IReadOnlyList Methods => [new("bonk", "Bonk")]; @@ -47,7 +42,47 @@ public void Extract(Stream stream, string outputDir, string? password, string[]? public void ExtractEntry(Stream input, string entryName, Stream output, string? password) => AudioPseudoArchive.ExtractEntry(BuildEntries(input), entryName, output); - // ── Shared archive-entry builder ───────────────────────────────────────────── + public long? MaxTotalArchiveSize => null; + public string AcceptedInputsDescription => + "Bonk archive accepts: FULL.bonk or one/two mono 16-bit PCM WAV channel files"; + + public bool CanAccept(ArchiveInputInfo input, out string? reason) { + var name = Path.GetFileName(input.ArchiveName); + if (name.Equals("FULL.bonk", StringComparison.OrdinalIgnoreCase) || + name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { + reason = null; + return true; + } + reason = $"not a Bonk-archive input (got {input.ArchiveName}); {AcceptedInputsDescription}"; + return false; + } + + public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) { + var fileList = FormatHelpers.FilesOnly(inputs).ToList(); + var full = fileList.FirstOrDefault(f => + Path.GetFileName(f.Name).Equals("FULL.bonk", StringComparison.OrdinalIgnoreCase)); + if (full.Data != null) { + output.Write(full.Data); + return; + } + + var channelBlobs = fileList + .Where(f => Path.GetFileName(f.Name).EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => ChannelLayout.OrderIndex(Path.GetFileNameWithoutExtension(f.Name))) + .ToList(); + if (channelBlobs.Count is < 1 or > 2) + throw new InvalidOperationException("Bonk create requires one or two mono PCM16 WAV channel files."); + + var channels = channelBlobs.Select(b => new WavReader().Read(b.Data)).ToList(); + var first = channels[0]; + if (channels.Any(c => c.NumChannels != 1 || c.BitsPerSample != 16)) + throw new InvalidOperationException("Bonk create requires mono 16-bit integer PCM WAV channel files."); + if (channels.Any(c => c.SampleRate != first.SampleRate || c.InterleavedPcm.Length != first.InterleavedPcm.Length)) + throw new InvalidOperationException("All Bonk channel WAVs must share sample rate and frame count."); + + var interleaved = PcmCodec.Interleave(channels.Select(c => c.InterleavedPcm).ToList(), 16); + output.Write(BonkCodec.Compress(interleaved, channels.Count, first.SampleRate)); + } private static IReadOnlyList BuildEntries(Stream stream) { using var ms = new MemoryStream(); diff --git a/FileFormats/FileFormat.Midi/MidiFormatDescriptor.cs b/FileFormats/FileFormat.Midi/MidiFormatDescriptor.cs index 1965b9f99..f5edb87db 100644 --- a/FileFormats/FileFormat.Midi/MidiFormatDescriptor.cs +++ b/FileFormats/FileFormat.Midi/MidiFormatDescriptor.cs @@ -11,14 +11,17 @@ namespace FileFormat.Midi; /// track_NN_<name>.mid per MTrk chunk (re-wrapped as a format-0 /// single-track file), one metadata.ini carrying song title / copyright / /// tempo / time signature, and lyrics.txt if lyric meta-events are present. +/// The descriptor can create a fresh SMF by passing through FULL.mid or by +/// combining extracted single-track files with a shared timing division. /// -public sealed class MidiFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveInMemoryExtract, IArchiveWriteConstraints { +public sealed class MidiFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, + IArchiveInMemoryExtract, IArchiveWriteConstraints, IArchiveCreatable { public string Id => "Midi"; public string DisplayName => "MIDI (Standard MIDI File)"; public FormatCategory Category => FormatCategory.Audio; public FormatCapabilities Capabilities => FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest | - FormatCapabilities.SupportsMultipleEntries; + FormatCapabilities.CanCreate | FormatCapabilities.SupportsMultipleEntries; public string DefaultExtension => ".mid"; public IReadOnlyList Extensions => [".mid", ".midi"]; public IReadOnlyList CompoundExtensions => []; @@ -55,6 +58,43 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string? throw new FileNotFoundException($"Entry not found: {entryName}"); } + public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) { + var files = FormatHelpers.FilesOnly(inputs).ToList(); + var full = files.FirstOrDefault(f => + Path.GetFileName(f.Name).Equals("FULL.mid", StringComparison.OrdinalIgnoreCase)); + if (full.Data != null) { + // Validate before passthrough so Create never emits an arbitrary blob under a .mid name. + _ = new MidiCodec().ReadHeader(full.Data); + output.Write(full.Data); + return; + } + + var trackFiles = files + .Where(f => Path.GetFileName(f.Name).StartsWith("track_", StringComparison.OrdinalIgnoreCase) && + Path.GetFileName(f.Name).EndsWith(".mid", StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => Path.GetFileName(f.Name), StringComparer.OrdinalIgnoreCase) + .ToList(); + if (trackFiles.Count == 0) + throw new InvalidOperationException("MIDI create needs FULL.mid or one or more track_NN_*.mid files."); + + var codec = new MidiCodec(); + var bodies = new List(trackFiles.Count); + int? division = null; + foreach (var file in trackFiles) { + var header = codec.ReadHeader(file.Data); + var tracks = codec.FindTracks(file.Data); + if (tracks.Count != 1) + throw new InvalidOperationException($"{file.Name} must contain exactly one MTrk chunk."); + if (division.HasValue && header.Division != division.Value) + throw new InvalidOperationException("All MIDI track inputs must use the same timing division."); + division ??= header.Division; + bodies.Add(codec.ExtractTrackBytes(file.Data, tracks[0])); + } + + var format = bodies.Count == 1 ? 0 : 1; + output.Write(MidiWriter.BuildFile(bodies, division!.Value, format)); + } + private static IReadOnlyList<(string Name, string Kind, byte[] Data)> BuildEntries(Stream stream) { using var ms = new MemoryStream(); stream.CopyTo(ms); @@ -106,7 +146,6 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string? } } - // Per-track format-0 files. foreach (var t in tracks) { trackNames.TryGetValue(t.Index, out var name); var safeName = Sanitize(name) ?? "untitled"; @@ -115,7 +154,6 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string? entries.Add(($"track_{t.Index:D2}_{safeName}.mid", "Track", trackFile)); } - // Metadata ini. var ini = new StringBuilder(); ini.AppendLine("; SMF metadata"); ini.Append("format=").AppendLine(header.Format.ToString(CultureInfo.InvariantCulture)); @@ -134,14 +172,12 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string? return entries; } - // ── IArchiveWriteConstraints ────────────────────────────────────────────── - public long? MaxTotalArchiveSize => null; public string AcceptedInputsDescription => "MIDI archive accepts: FULL.mid, track_NN_*.mid, metadata.ini, lyrics.txt"; public bool CanAccept(ArchiveInputInfo input, out string? reason) { - var name = System.IO.Path.GetFileName(input.ArchiveName).ToLowerInvariant(); + var name = Path.GetFileName(input.ArchiveName).ToLowerInvariant(); if (name is "full.mid" or "metadata.ini" or "lyrics.txt" || (name.StartsWith("track_") && name.EndsWith(".mid"))) { reason = null; return true; diff --git a/Hawkynt.FileFormats.Audio/Hawkynt.FileFormats.Audio.csproj b/Hawkynt.FileFormats.Audio/Hawkynt.FileFormats.Audio.csproj index 06bd80fd8..c15bed3a7 100644 --- a/Hawkynt.FileFormats.Audio/Hawkynt.FileFormats.Audio.csproj +++ b/Hawkynt.FileFormats.Audio/Hawkynt.FileFormats.Audio.csproj @@ -70,6 +70,8 @@ + + @@ -79,6 +81,7 @@ + @@ -90,6 +93,8 @@ + + @@ -98,6 +103,7 @@ + diff --git a/Hawkynt.FileFormats.Audio/README.md b/Hawkynt.FileFormats.Audio/README.md index 4cf9a4f8c..d1d218641 100644 --- a/Hawkynt.FileFormats.Audio/README.md +++ b/Hawkynt.FileFormats.Audio/README.md @@ -357,7 +357,7 @@ The audio package is built against the repository's shared Core version and shou -Every public and protected member of all 100 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Audio/REFERENCE.md). +Every public and protected member of all 109 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.Audio/REFERENCE.md). diff --git a/Hawkynt.FileFormats.Audio/REFERENCE.md b/Hawkynt.FileFormats.Audio/REFERENCE.md index 5d7f9a861..3a1cfbe58 100644 --- a/Hawkynt.FileFormats.Audio/REFERENCE.md +++ b/Hawkynt.FileFormats.Audio/REFERENCE.md @@ -150,6 +150,53 @@ Implements `IEquatable`. | `SampleRateIndex` | `int SampleRateIndex { get; init; }` | | | `SampleRate` | `int SampleRate { get; init; }` | | +### Namespace `Codec.Bonk` + +[`BonkCodec`](#bonkcodec) · [`BonkCodec.BonkStreamInfo`](#bonkcodecbonkstreaminfo) + +#### `BonkCodec` + +Bonk audio decoder, ported from ffmpeg `libavcodec/bonk.c` (and the file layout from `libavformat/bonk.c`). Bonk uses an adaptive lattice (LPC) of up to 2048 taps whose coefficients are sent per packet through an adaptive Golomb-style integer-list coder (`intlist_read`), then predicts each sample via the lattice. Optional mid/side stereo and integer downsampling are supported. Only the lossless path is exercised for verification (a crafted packet round-trips byte-exact); lossy quantisation is honoured per the source. The on-disk file is a `'\0BONK'` tag followed by a 17-byte header (version, total-samples, sample-rate, channels, lossless / mid-side flags, tap count, downsampling, samples-per-packet), then the raw bitstream of all packets. The decoder buffers the whole bitstream and decodes packets until the declared sample count is exhausted. + +| Member | Signature | Summary | +| --- | --- | --- | +| `HeaderBytes` | `const int HeaderBytes` | | +| `Compress` | `static byte[] Compress(ReadOnlySpan interleavedPcm, int channels, int sampleRate, int nTaps = 4, int samplesPerPacket = 256)` | Encodes raw interleaved little-endian 16-bit PCM to a complete Bonk file in lossless mode. Coefficients are sent as all-zero taps (so the lattice is a pass-through) and each packet's samples are coded through the canonical inverse of `ReadIntList`; the produced stream decodes back to the exact input. Intended for deterministic round-trip verification. | +| `Decompress` | `static byte[] Decompress(ReadOnlySpan file)` | Decodes a Bonk file to raw interleaved little-endian 16-bit PCM. | +| `ReadStreamInfo` | `static BonkStreamInfo ReadStreamInfo(ReadOnlySpan file, out int dataOffset)` | Reads the `'\0BONK'` tag + 17-byte header from the start of a Bonk file. | + +#### `BonkCodec.BonkStreamInfo` + +Decoded stream geometry. + +Implements `IEquatable`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `BonkStreamInfo` | `BonkStreamInfo(int Channels, int SampleRate, long SamplesPerChannel, bool Lossless, bool MidSide, int NTaps, int DownSampling, int SamplesPerPacket)` | Decoded stream geometry. | +| `Channels` | `int Channels { get; init; }` | | +| `DownSampling` | `int DownSampling { get; init; }` | | +| `Lossless` | `bool Lossless { get; init; }` | | +| `MidSide` | `bool MidSide { get; init; }` | | +| `NTaps` | `int NTaps { get; init; }` | | +| `SampleRate` | `int SampleRate { get; init; }` | | +| `SamplesPerChannel` | `long SamplesPerChannel { get; init; }` | | +| `SamplesPerPacket` | `int SamplesPerPacket { get; init; }` | | + +### Namespace `Codec.Dfpwm` + +[`DfpwmCodec`](#dfpwmcodec) + +#### `DfpwmCodec` + +DFPWM1a (Dynamic Filter Pulse Width Modulation, "1a" variant) codec — the 1-bit-per-sample scheme used by ComputerCraft speakers. The decoder is ported verbatim from ffmpeg `libavcodec/dfpwmdec.c`: a predictive charge integrator with an adaptive strength and an anti-jerk plus first-order low-pass output filter. Each input byte yields 8 unsigned-8 PCM samples, decoded LSB-first. The encoder is the matching ffmpeg `dfpwmenc.c` algorithm so a round-trip is stable. DFPWM is headerless: callers must know the sample rate (ComputerCraft uses 48000 Hz mono by convention) and channel count out of band. + +| Member | Signature | Summary | +| --- | --- | --- | +| `DefaultSampleRate` | `const int DefaultSampleRate` | Default sample rate for raw DFPWM (ComputerCraft convention). | +| `Compress` | `static byte[] Compress(ReadOnlySpan pcmU8)` | Encodes unsigned 8-bit PCM to DFPWM1a (8 samples → one byte). Mirrors ffmpeg's `dfpwm_enc`: the same predictive integrator with the anti-jerk handling, emitting one bit per sample LSB-first. A trailing partial byte is zero-padded. | +| `Decompress` | `static byte[] Decompress(ReadOnlySpan dfpwm)` | Decodes raw DFPWM1a bytes to unsigned 8-bit PCM (one byte → 8 samples). The state machine matches ffmpeg's `au_decompress` exactly. | + ### Namespace `Codec.Flac` [`FlacCodec`](#flaccodec) · [`FlacCodec.AudioProperties`](#flaccodecaudioproperties) @@ -199,16 +246,18 @@ GSM 06.10 full-rate speech decoder (ETSI EN 300 961). Each 33-byte frame decodes #### `ImaAdpcmCodec` -IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) decoder. Each 4-bit nibble encodes the magnitude + sign of the delta between samples; the step size walks up and down a 89-entry log-spaced table based on the previous nibble. Used by WAV format code 0x0011 with a block layout: Per-channel 4-byte header: int16 predictor, int8 step-index, 1 reserved byte.For mono: remaining `blockAlign - 4` bytes are nibble pairs (LSN first).For stereo: headers are interleaved per channel (4 bytes L, 4 bytes R), then nibbles are interleaved 4 bytes per channel (8 samples each). +IMA ADPCM (Interactive Multimedia Association Adaptive Differential PCM) codec. Supports the Microsoft/Intel WAV block layout and Apple/QuickTime `ima4` packets. | Member | Signature | Summary | | --- | --- | --- | -| `DecodeQuickTime` | `static short[][] DecodeQuickTime(ReadOnlySpan data, int channels)` | Decodes the Apple/QuickTime `ima4` packet variant (as carried by AIFC) into one PCM buffer per channel. The data is a sequence of fixed 34-byte packets that round-robin through the channels (ch0, ch1, …, ch0, …). Each packet is: a 2-byte big-endian preamble: the top 9 bits are the signed initial predictor (`(short)(preamble & 0xFF80)`) and the low 7 bits are the initial step index (clamped to ≤ 88);32 data bytes = 64 nibbles, low nibble first within each byte, decoded with the standard IMA step tables. Every packet therefore yields exactly 64 samples for its channel. Unlike the WAV block layout the packet does not emit the predictor itself as a sample. | +| `DecodeQuickTime` | `static short[][] DecodeQuickTime(ReadOnlySpan data, int channels)` | Decodes the Apple/QuickTime `ima4` packet variant (as carried by AIFC) into one PCM buffer per channel. Packets are 34 bytes and round-robin through channels. | | `Decode` | `static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int channels)` | Decodes IMA ADPCM data to one PCM buffer per channel. Each output buffer holds `((blockAlign/channels - 4) * 2 + 1)` samples per block. | +| `EncodeQuickTime` | `static byte[] EncodeQuickTime(IReadOnlyList pcm)` | Encodes equal-length PCM16 channel buffers to Apple/QuickTime `ima4` packets. A final partial packet is padded with the last reconstructed sample. Packets are emitted in the channel-round-robin order used by AIFC/QuickTime. | +| `Encode` | `static byte[] Encode(IReadOnlyList pcm, int blockAlign)` | Encodes one or two equal-length PCM16 channel buffers to Microsoft/Intel IMA ADPCM WAV blocks. The final block is padded with the last reconstructed sample so the raw coded stream remains block-aligned; a container can retain the exact source sample count in its own metadata. | ### Namespace `Codec.Midi` -[`MidiCodec`](#midicodec) · [`MidiCodec.FileHeader`](#midicodecfileheader) · [`MidiCodec.MetaEvent`](#midicodecmetaevent) · [`MidiCodec.TrackChunk`](#midicodectrackchunk) +[`MidiCodec`](#midicodec) · [`MidiCodec.FileHeader`](#midicodecfileheader) · [`MidiCodec.MetaEvent`](#midicodecmetaevent) · [`MidiCodec.TrackChunk`](#midicodectrackchunk) · [`MidiWriter`](#midiwriter) #### `MidiCodec` @@ -256,6 +305,14 @@ Implements `IEquatable`. | `FileOffset` | `int FileOffset { get; init; }` | | | `Index` | `int Index { get; init; }` | | +#### `MidiWriter` + +Standard MIDI File emitter for already-encoded `MTrk` payloads. + +| Member | Signature | Summary | +| --- | --- | --- | +| `BuildFile` | `static byte[] BuildFile(IReadOnlyList trackBodies, int division, int format = 1)` | Builds an SMF from raw `MTrk` payloads. Format 0 requires exactly one track; formats 1 and 2 may contain multiple tracks. Event bytes are preserved verbatim. | + ### Namespace `Codec.Mp3` [`Mp3Codec`](#mp3codec) · [`Mp3FrameHeader`](#mp3frameheader) · [`Mp3StreamInfo`](#mp3streaminfo) @@ -322,11 +379,12 @@ Implements `IEquatable`. #### `MsAdpcmCodec` -Microsoft ADPCM decoder (WAV format code 0x0002). Block layout: Per channel: 1-byte predictor selector (index into the 7-entry coefficient table), 2-byte delta (quantization step), 2-byte sample1, 2-byte sample2.Followed by `blockAlign - 7*channels` bytes of ADPCM nibbles, where each byte packs two samples (high nibble first) that alternate channels when stereo. Predictor coefficients are taken from a standard 7-entry table; the new sample is computed as `(s1 * c1 + s2 * c2) >> 8` plus a dequantized delta, with the delta itself adapting based on an error table. +Microsoft ADPCM codec (WAV format code 0x0002). Uses the canonical seven adaptive predictor pairs and the Microsoft 4-bit delta adaptation table. | Member | Signature | Summary | | --- | --- | --- | | `Decode` | `static short[][] Decode(ReadOnlySpan adpcm, int blockAlign, int channels)` | Decodes a buffer of MS-ADPCM blocks to per-channel PCM. Each block emits `2 + (blockAlign - 7*channels) * 2 / channels` samples per channel. | +| `Encode` | `static byte[] Encode(IReadOnlyList pcm, int blockAlign)` | Encodes one or two equal-length PCM16 channel buffers to Microsoft ADPCM WAV blocks. For each block the encoder searches all seven standard predictor pairs and a logarithmic set of legal starting deltas, retaining the combination with the lowest reconstruction error. The final block is padded with the last reconstructed sample. | ### Namespace `Codec.MuLaw` @@ -532,6 +590,35 @@ PCM codec: integer/float sample packing, channel interleave/deinterleave, and ca | `SplitPerChannelIntSamples` | `static IReadOnlyList> SplitPerChannelIntSamples(int[][] perChannel, int sampleRate, int bitsPerSample)` | Splits per-channel integer samples into per-channel mono WAV blobs. Widths wider than `bitsPerSample` are truncated via two's-complement masking. | | `ToWavBlob` | `static byte[] ToWavBlob(byte[] pcm, int channels, int sampleRate, int bitsPerSample, int formatCode = 1)` | Wraps raw little-endian PCM bytes in a minimal RIFF/WAVE header. `formatCode`: 1 = PCM integer, 3 = IEEE float. | +### Namespace `Codec.Qoa` + +[`QoaCodec`](#qoacodec) · [`QoaCodec.QoaStreamInfo`](#qoacodecqoastreaminfo) + +#### `QoaCodec` + +Quite OK Audio (QOA) lossy-but-deterministic codec — decoder plus a faithful encoder. QOA is a fixed-bitrate (~3.2 bits/sample) DPCM scheme: audio is split into frames of up to 256 slices per channel; each slice codes 20 samples as a 4-bit scale-factor index followed by 20 × 3-bit residual indices. A per-channel order-4 sign-LMS predictor (the `>>13` prediction shift and `residual>>4` weight update) reconstructs each sample, and a fixed dequantisation table (`DequantTab`) maps residual indices to signed deltas. The whole pipeline is integer and deterministic, so re-encoding a decoded stream is byte-stable and the decoder reproduces the reference output exactly. The on-disk layout: an 8-byte file header (`'qoaf'` magic, then a 32-bit big-endian total-samples-per-channel count); then frames. Each frame opens with an 8-byte big-endian header packing channels (8 bits), sample-rate (24 bits), frame samples-per-channel (16 bits) and frame byte-size (16 bits), followed by per-channel 16-byte LMS state (4 × s16 history then 4 × s16 weights) and the interleaved slices. Tables, predictor and bit packing are ported verbatim from the reference `qoa.h` / ffmpeg `libavcodec/qoadec.c`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `DequantTab` | `static readonly int[][] DequantTab` | qoa_dequant_tab[16][8] — ported verbatim from qoa.h. | +| `ScaleFactorTab` | `static readonly int[] ScaleFactorTab` | qoa_scalefactor_tab — index → reciprocal-style scale used during encode. | +| `Compress` | `static void Compress(Stream pcmInput, Stream qoaOutput, int channels, int sampleRate)` | Encodes raw interleaved little-endian 16-bit PCM to a QOA stream. The encoder mirrors the reference: it brute-forces the best of the 16 scale-factors per slice by minimising squared error against the dequantised reconstruction, so its output decodes back to exactly the samples this codec would reconstruct. | +| `Decompress` | `static void Decompress(Stream qoaInput, Stream pcmOutput)` | Decodes a QOA stream to raw interleaved little-endian 16-bit PCM. | +| `ReadStreamInfo` | `static QoaStreamInfo ReadStreamInfo(Stream input)` | Reads the QOA file/first-frame headers without decoding audio. | + +#### `QoaCodec.QoaStreamInfo` + +Stream geometry exposed to container descriptors. + +Implements `IEquatable`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `QoaStreamInfo` | `QoaStreamInfo(int Channels, int SampleRate, long SamplesPerChannel)` | Stream geometry exposed to container descriptors. | +| `Channels` | `int Channels { get; init; }` | | +| `SampleRate` | `int SampleRate { get; init; }` | | +| `SamplesPerChannel` | `long SamplesPerChannel { get; init; }` | | + ### Namespace `Codec.Vorbis` [`VorbisCodec`](#vorbiscodec) · [`VorbisStreamInfo`](#vorbisstreaminfo) @@ -920,6 +1007,72 @@ Implements `IDisposable`. | `Dispose` | `void Dispose()` | | | `Finish` | `void Finish()` | Writes the AFS2 container and finalizes the stream. | +### Namespace `FileFormat.Bonk` + +[`BonkFormatDescriptor`](#bonkformatdescriptor) + +#### `BonkFormatDescriptor` + +Exposes a Bonk (`.bonk`) file as a pseudo-archive of `FULL.bonk` plus, when the bitstream decodes, one mono WAV per channel and a `metadata.ini` tag. The descriptor is creatable (WORM): it passes through `FULL.bonk` or assembles a new lossless Bonk stream from one or two mono PCM16 WAV channel files. + +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWriteConstraints`, `IFormatDescriptor`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `BonkFormatDescriptor` | `BonkFormatDescriptor()` | | +| `AcceptedInputsDescription` | `string AcceptedInputsDescription { get; }` | | +| `Capabilities` | `FormatCapabilities Capabilities { get; }` | | +| `Category` | `FormatCategory Category { get; }` | | +| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | | +| `DefaultExtension` | `string DefaultExtension { get; }` | | +| `Description` | `string Description { get; }` | | +| `DisplayName` | `string DisplayName { get; }` | | +| `Extensions` | `IReadOnlyList Extensions { get; }` | | +| `Family` | `AlgorithmFamily Family { get; }` | | +| `Id` | `string Id { get; }` | | +| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | | +| `MaxTotalArchiveSize` | `long? MaxTotalArchiveSize { get; }` | | +| `Methods` | `IReadOnlyList Methods { get; }` | | +| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | | +| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | +| `ExtractEntry` | `void ExtractEntry(Stream input, string entryName, Stream output, string password)` | | +| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | +| `List` | `List List(Stream stream, string password)` | | + +### Namespace `FileFormat.Dfpwm` + +[`DfpwmFormatDescriptor`](#dfpwmformatdescriptor) + +#### `DfpwmFormatDescriptor` + +Exposes a DFPWM1a (`.dfpwm`) file as a pseudo-archive of `FULL.dfpwm` (Kind `Container`) plus the single decoded mono channel as `MONO.wav` (Kind `Channel`, 8-bit unsigned PCM) and a `metadata.ini` (Kind `Tag`). DFPWM is headerless and carries no sample rate or channel count, so the surfaced WAV assumes mono at `DefaultSampleRate` (48000 Hz — the ComputerCraft convention); detection is by extension only. The descriptor is creatable (WORM): it passes a supplied `FULL.dfpwm` through unchanged or encodes a mono 8-bit WAV with `DfpwmCodec`. + +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWriteConstraints`, `IFormatDescriptor`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `DfpwmFormatDescriptor` | `DfpwmFormatDescriptor()` | | +| `AcceptedInputsDescription` | `string AcceptedInputsDescription { get; }` | | +| `Capabilities` | `FormatCapabilities Capabilities { get; }` | | +| `Category` | `FormatCategory Category { get; }` | | +| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | | +| `DefaultExtension` | `string DefaultExtension { get; }` | | +| `Description` | `string Description { get; }` | | +| `DisplayName` | `string DisplayName { get; }` | | +| `Extensions` | `IReadOnlyList Extensions { get; }` | | +| `Family` | `AlgorithmFamily Family { get; }` | | +| `Id` | `string Id { get; }` | | +| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | | +| `MaxTotalArchiveSize` | `long? MaxTotalArchiveSize { get; }` | | +| `Methods` | `IReadOnlyList Methods { get; }` | | +| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | | +| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | +| `ExtractEntry` | `void ExtractEntry(Stream input, string entryName, Stream output, string password)` | | +| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | +| `List` | `List List(Stream stream, string password)` | | + ### Namespace `FileFormat.Flac` [`FlacArchiveDescriptor`](#flacarchivedescriptor) · [`FlacFormatDescriptor`](#flacformatdescriptor) · [`FlacLayoutMap`](#flaclayoutmap) · [`FlacReader`](#flacreader) · [`FlacReader.AudioProperties`](#flacreaderaudioproperties) · [`FlacWriter`](#flacwriter) @@ -1081,9 +1234,9 @@ Implements `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IFormatDescri #### `MidiFormatDescriptor` -Surfaces a Standard MIDI File as an archive: one `FULL.mid`, one `track_NN_.mid` per `MTrk` chunk (re-wrapped as a format-0 single-track file), one `metadata.ini` carrying song title / copyright / tempo / time signature, and `lyrics.txt` if lyric meta-events are present. +Surfaces a Standard MIDI File as an archive: one `FULL.mid`, one `track_NN_.mid` per `MTrk` chunk (re-wrapped as a format-0 single-track file), one `metadata.ini` carrying song title / copyright / tempo / time signature, and `lyrics.txt` if lyric meta-events are present. The descriptor can create a fresh SMF by passing through `FULL.mid` or by combining extracted single-track files with a shared timing division. -Implements `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWriteConstraints`, `IFormatDescriptor`. +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWriteConstraints`, `IFormatDescriptor`. | Member | Signature | Summary | | --- | --- | --- | @@ -1103,6 +1256,7 @@ Implements `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWrite | `Methods` | `IReadOnlyList Methods { get; }` | | | `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | | | `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | | `ExtractEntry` | `void ExtractEntry(Stream input, string entryName, Stream output, string password)` | | | `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | | `List` | `List List(Stream stream, string password)` | | @@ -1429,6 +1583,39 @@ Implements `IDisposable`. | `Dispose` | `void Dispose()` | | | `Finish` | `void Finish()` | Serializes all fields to the underlying stream. Idempotent. | +### Namespace `FileFormat.Qoa` + +[`QoaFormatDescriptor`](#qoaformatdescriptor) + +#### `QoaFormatDescriptor` + +Exposes a Quite OK Audio (`.qoa`) file as a pseudo-archive of `FULL.qoa` (Kind `Container`) plus, when the bitstream decodes, one mono WAV per channel (Kind `Channel`, named via `ChannelLayout`) and a `metadata.ini` (Kind `Tag`). Decode failures degrade gracefully to a FULL-only listing. The descriptor is also creatable (WORM): it passes a supplied `FULL.qoa` through unchanged or interleaves per-channel mono WAVs and encodes them with `QoaCodec`. + +Implements `IArchiveCreatable`, `IArchiveFormatOperations`, `IArchiveInMemoryExtract`, `IArchiveWriteConstraints`, `IFormatDescriptor`. + +| Member | Signature | Summary | +| --- | --- | --- | +| `QoaFormatDescriptor` | `QoaFormatDescriptor()` | | +| `AcceptedInputsDescription` | `string AcceptedInputsDescription { get; }` | | +| `Capabilities` | `FormatCapabilities Capabilities { get; }` | | +| `Category` | `FormatCategory Category { get; }` | | +| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | | +| `DefaultExtension` | `string DefaultExtension { get; }` | | +| `Description` | `string Description { get; }` | | +| `DisplayName` | `string DisplayName { get; }` | | +| `Extensions` | `IReadOnlyList Extensions { get; }` | | +| `Family` | `AlgorithmFamily Family { get; }` | | +| `Id` | `string Id { get; }` | | +| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | | +| `MaxTotalArchiveSize` | `long? MaxTotalArchiveSize { get; }` | | +| `Methods` | `IReadOnlyList Methods { get; }` | | +| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | | +| `CanAccept` | `bool CanAccept(ArchiveInputInfo input, out string reason)` | | +| `Create` | `void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)` | | +| `ExtractEntry` | `void ExtractEntry(Stream input, string entryName, Stream output, string password)` | | +| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | | +| `List` | `List List(Stream stream, string password)` | | + ### Namespace `FileFormat.S3m` [`S3mFormatDescriptor`](#s3mformatdescriptor)