diff --git a/Compression.Lib/AudioAdapterResolver.cs b/Compression.Lib/AudioAdapterResolver.cs
new file mode 100644
index 000000000..5e3ff6605
--- /dev/null
+++ b/Compression.Lib/AudioAdapterResolver.cs
@@ -0,0 +1,49 @@
+using Compression.Registry;
+
+namespace Compression.Lib;
+
+/// Central resolver for native and non-invasive audio conversion capabilities.
+internal static class AudioAdapterResolver {
+ private static readonly WavAudioAdapter Wav = new();
+ private static readonly CafAudioAdapter Caf = new();
+ private static readonly Ac3AudioAdapter Ac3 = new();
+ private static readonly DtsAudioAdapter Dts = new();
+
+ public static IAudioPcmSource? ResolvePcmSource(IFormatDescriptor descriptor)
+ => descriptor as IAudioPcmSource ?? descriptor.Id switch {
+ "Wav" => Wav,
+ "Caf" => Caf,
+ "Ac3" => Ac3,
+ "Dts" => Dts,
+ _ => AudioFormatAdapters.ResolvePcmSource(descriptor),
+ };
+
+ public static IAudioPcmTarget? ResolvePcmTarget(IFormatDescriptor descriptor)
+ => descriptor as IAudioPcmTarget ?? descriptor.Id switch {
+ "Wav" => Wav,
+ "Caf" => Caf,
+ "Ac3" => Ac3,
+ "Dts" => Dts,
+ _ => AudioFormatAdapters.ResolvePcmTarget(descriptor),
+ };
+
+ public static IAudioDemuxSource? ResolveDemuxSource(IFormatDescriptor descriptor)
+ => descriptor as IAudioDemuxSource ?? descriptor.Id switch {
+ "Mp3" => Mp3AudioPacketAdapter.Instance,
+ "WavPack" => WavPackAudioPacketAdapter.Instance,
+ _ => null,
+ };
+
+ public static IAudioMuxTarget? ResolveMuxTarget(IFormatDescriptor descriptor)
+ => descriptor as IAudioMuxTarget ?? descriptor.Id switch {
+ "Mp3" => Mp3AudioPacketAdapter.Instance,
+ "WavPack" => WavPackAudioPacketAdapter.Instance,
+ _ => null,
+ };
+
+ public static IArchiveCreatable? ResolvePseudoArchiveTarget(IFormatDescriptor descriptor)
+ => descriptor is IArchiveCreatable creator &&
+ (descriptor.Category == FormatCategory.Audio || descriptor is IAudioContainerFormat)
+ ? creator
+ : null;
+}
diff --git a/Compression.Lib/AudioConversionInventory.cs b/Compression.Lib/AudioConversionInventory.cs
new file mode 100644
index 000000000..488718bb4
--- /dev/null
+++ b/Compression.Lib/AudioConversionInventory.cs
@@ -0,0 +1,67 @@
+using Compression.Registry;
+
+namespace Compression.Lib;
+
+/// Describes how one registered format participates in the audio conversion graph.
+public sealed record AudioConversionCapability(
+ string FormatId,
+ string DisplayName,
+ bool CanDecodePcm,
+ bool CanEncodePcm,
+ bool CanDemuxEncoded,
+ bool CanMuxEncoded,
+ bool CanReadPseudoArchive,
+ bool CanCreatePseudoArchive,
+ IReadOnlyList EncodeCodecs,
+ IReadOnlyList MuxCodecs
+) {
+ public bool CanBeSource => this.CanDecodePcm || this.CanDemuxEncoded || this.CanReadPseudoArchive;
+ public bool CanBeTarget => this.CanEncodePcm || this.CanMuxEncoded || this.CanCreatePseudoArchive;
+}
+
+///
+/// Enumerates the actual registered audio conversion surface. This is capability-based,
+/// not documentation-based: adding an encoder/muxer automatically changes the inventory.
+///
+public static class AudioConversionInventory {
+
+ public static IReadOnlyList Enumerate() {
+ FormatRegistry.Initialize();
+ return FormatRegistry.All
+ .Where(IsAudioCandidate)
+ .Select(Describe)
+ .OrderBy(static item => item.FormatId, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ }
+
+ public static AudioConversionCapability Describe(IFormatDescriptor descriptor) {
+ ArgumentNullException.ThrowIfNull(descriptor);
+
+ var pcmSource = AudioAdapterResolver.ResolvePcmSource(descriptor);
+ var pcmTarget = AudioAdapterResolver.ResolvePcmTarget(descriptor);
+ var demux = AudioAdapterResolver.ResolveDemuxSource(descriptor);
+ var mux = AudioAdapterResolver.ResolveMuxTarget(descriptor);
+ var archive = descriptor as IArchiveFormatOperations;
+ var creator = AudioAdapterResolver.ResolvePseudoArchiveTarget(descriptor);
+
+ return new AudioConversionCapability(
+ descriptor.Id,
+ descriptor.DisplayName,
+ pcmSource is not null,
+ pcmTarget is not null,
+ demux is not null,
+ mux is not null,
+ archive is not null,
+ creator is not null,
+ pcmTarget?.SupportedEncodeCodecs.ToArray() ?? [],
+ mux?.SupportedMuxCodecs.ToArray() ?? []);
+ }
+
+ private static bool IsAudioCandidate(IFormatDescriptor descriptor)
+ => descriptor.Category == FormatCategory.Audio
+ || descriptor is IAudioContainerFormat
+ || AudioAdapterResolver.ResolvePcmSource(descriptor) is not null
+ || AudioAdapterResolver.ResolvePcmTarget(descriptor) is not null
+ || AudioAdapterResolver.ResolveDemuxSource(descriptor) is not null
+ || AudioAdapterResolver.ResolveMuxTarget(descriptor) is not null;
+}
diff --git a/Compression.Lib/AudioConversionOperation.cs b/Compression.Lib/AudioConversionOperation.cs
new file mode 100644
index 000000000..103983740
--- /dev/null
+++ b/Compression.Lib/AudioConversionOperation.cs
@@ -0,0 +1,207 @@
+using Codec.Pcm;
+using Compression.Registry;
+using FileFormat.Wav;
+
+namespace Compression.Lib;
+
+///
+/// Capability-driven audio conversion. Routes the least destructive path first:
+/// byte-exact passthrough, encoded packet remux, canonical PCM transcode, then the
+/// legacy per-channel WAV pseudo-archive bridge.
+///
+public static class AudioConversionOperation {
+
+ public static void Convert(
+ Stream input,
+ string sourceFormatId,
+ Stream output,
+ string targetFormatId,
+ FormatCreateOptions? options = null
+ ) {
+ ArgumentNullException.ThrowIfNull(sourceFormatId);
+ ArgumentNullException.ThrowIfNull(targetFormatId);
+
+ FormatRegistry.Initialize();
+ var source = FormatRegistry.GetById(sourceFormatId)
+ ?? throw new ArgumentException($"Unknown source format '{sourceFormatId}'.", nameof(sourceFormatId));
+ var target = FormatRegistry.GetById(targetFormatId)
+ ?? throw new ArgumentException($"Unknown target format '{targetFormatId}'.", nameof(targetFormatId));
+ Convert(input, source, output, target, options);
+ }
+
+ public static void Convert(
+ Stream input,
+ IFormatDescriptor source,
+ Stream output,
+ IFormatDescriptor target,
+ FormatCreateOptions? options = null
+ ) {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(target);
+ options ??= new FormatCreateOptions();
+
+ var outputCodecExplicitlyRequested =
+ !string.IsNullOrWhiteSpace(options.MethodName) || options.HasOption("codec");
+ if (source.Id.Equals(target.Id, StringComparison.OrdinalIgnoreCase) && !outputCodecExplicitlyRequested) {
+ Rewind(input);
+ input.CopyTo(output);
+ return;
+ }
+
+ var demux = AudioAdapterResolver.ResolveDemuxSource(source);
+ var mux = AudioAdapterResolver.ResolveMuxTarget(target);
+ if (demux is not null && mux is not null) {
+ Rewind(input);
+ if (demux.TryDemux(input, out var encoded) && encoded is not null &&
+ mux.SupportedMuxCodecs.Contains(encoded.Format.CodecId, StringComparer.OrdinalIgnoreCase) &&
+ mux.CanMux(encoded.Format, options, out _)) {
+ mux.Mux(output, encoded, options);
+ return;
+ }
+ }
+
+ var pcmTarget = AudioAdapterResolver.ResolvePcmTarget(target);
+ if (pcmTarget is not null) {
+ AudioPcmBuffer? pcm = null;
+ if (AudioAdapterResolver.ResolvePcmSource(source) is { } pcmSource) {
+ Rewind(input);
+ pcm = pcmSource.DecodePcm(input);
+ } else if (TryDecodePseudoArchivePcm(input, source, out var bridgedPcm)) {
+ pcm = bridgedPcm;
+ }
+
+ if (pcm is not null) {
+ var codec = ResolveCodec(pcmTarget, options);
+ if (!pcmTarget.CanEncode(pcm.Format, codec, options, out var reason))
+ throw new NotSupportedException(
+ $"{target.Id} cannot encode {pcm.Format.Channels}ch/{pcm.Format.SampleRate}Hz/" +
+ $"{pcm.Format.BitsPerSample}-bit PCM as '{codec}': {reason ?? "unsupported combination"}.");
+ pcmTarget.EncodePcm(output, pcm, codec, options);
+ return;
+ }
+ }
+
+ if (TryPseudoArchiveBridge(input, source, output, target, options))
+ return;
+
+ throw new NotSupportedException(
+ $"No audio conversion route exists from '{source.Id}' to '{target.Id}'. " +
+ "The source must expose encoded packets or PCM/channels and the target must expose a compatible mux/encode/create capability.");
+ }
+
+ private static string ResolveCodec(IAudioPcmTarget target, FormatCreateOptions options) {
+ if (!string.IsNullOrWhiteSpace(options.MethodName)) return options.MethodName;
+ var explicitCodec = options.GetOption("codec", string.Empty);
+ if (!string.IsNullOrWhiteSpace(explicitCodec)) return explicitCodec;
+ if (target.SupportedEncodeCodecs.Count == 0)
+ throw new NotSupportedException("The target advertises no audio encoder codecs.");
+ return target.SupportedEncodeCodecs[0];
+ }
+
+ private static bool TryDecodePseudoArchivePcm(
+ Stream input,
+ IFormatDescriptor source,
+ out AudioPcmBuffer? pcm
+ ) {
+ pcm = null;
+ if (source is not IArchiveFormatOperations sourceArchive) return false;
+
+ Rewind(input);
+ var listed = sourceArchive.List(input, password: null);
+ var entries = listed
+ .Where(static entry => entry.Kind.Equals("Channel", StringComparison.OrdinalIgnoreCase) &&
+ entry.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static entry => entry.Index)
+ .ToArray();
+
+ if (entries.Length == 0 && source.Id.Equals("Wav", StringComparison.OrdinalIgnoreCase)) {
+ var full = listed.FirstOrDefault(static entry =>
+ entry.Name.Equals("FULL.wav", StringComparison.OrdinalIgnoreCase));
+ if (full is not null) entries = [full];
+ }
+ if (entries.Length == 0) return false;
+
+ var decoded = new List(entries.Length);
+ foreach (var entry in entries) {
+ Rewind(input);
+ var bytes = sourceArchive.ExtractEntryToMemory(input, entry.Name, password: null);
+ decoded.Add(new WavReader().Read(bytes));
+ }
+
+ if (decoded.Count == 1 && decoded[0].NumChannels > 1) {
+ var only = decoded[0];
+ if (only.FormatCode is not (1 or 3)) return false;
+ pcm = new AudioPcmBuffer(
+ new AudioPcmFormat(
+ only.SampleRate,
+ only.NumChannels,
+ only.BitsPerSample,
+ only.FormatCode == 3 ? AudioPcmEncoding.IeeeFloat
+ : only.BitsPerSample == 8 ? AudioPcmEncoding.UnsignedInteger
+ : AudioPcmEncoding.SignedInteger),
+ only.InterleavedPcm);
+ return true;
+ }
+
+ var first = decoded[0];
+ if (first.NumChannels != 1 || first.FormatCode is not (1 or 3)) return false;
+ if (decoded.Any(channel => channel.NumChannels != 1 || channel.FormatCode != first.FormatCode ||
+ channel.BitsPerSample != first.BitsPerSample || channel.SampleRate != first.SampleRate ||
+ channel.InterleavedPcm.Length != first.InterleavedPcm.Length))
+ return false;
+
+ var interleaved = PcmCodec.Interleave(decoded.Select(static channel => channel.InterleavedPcm).ToList(), first.BitsPerSample);
+ pcm = new AudioPcmBuffer(
+ new AudioPcmFormat(
+ first.SampleRate,
+ decoded.Count,
+ first.BitsPerSample,
+ first.FormatCode == 3 ? AudioPcmEncoding.IeeeFloat
+ : first.BitsPerSample == 8 ? AudioPcmEncoding.UnsignedInteger
+ : AudioPcmEncoding.SignedInteger),
+ interleaved);
+ return true;
+ }
+
+ private static bool TryPseudoArchiveBridge(
+ Stream input,
+ IFormatDescriptor source,
+ Stream output,
+ IFormatDescriptor target,
+ FormatCreateOptions options
+ ) {
+ if (source is not IArchiveFormatOperations sourceArchive ||
+ AudioAdapterResolver.ResolvePseudoArchiveTarget(target) is not { } targetCreate)
+ return false;
+
+ Rewind(input);
+ var entries = sourceArchive.List(input, password: null)
+ .Where(static entry => entry.Kind.Equals("Channel", StringComparison.OrdinalIgnoreCase) &&
+ entry.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static entry => entry.Index)
+ .ToArray();
+ if (entries.Length == 0) return false;
+
+ var inputs = new List(entries.Length);
+ foreach (var entry in entries) {
+ Rewind(input);
+ var bytes = sourceArchive.ExtractEntryToMemory(input, entry.Name, password: null);
+ inputs.Add(ArchiveInputInfo.InMemory(entry.Name, bytes));
+ }
+
+ if (target is IArchiveWriteConstraints constraints)
+ foreach (var archiveInput in inputs)
+ if (!constraints.CanAccept(archiveInput, out var reason))
+ throw new NotSupportedException(
+ $"{target.Id} rejected converted channel '{archiveInput.ArchiveName}': {reason ?? "unsupported input"}.");
+
+ targetCreate.Create(output, inputs, options);
+ return true;
+ }
+
+ private static void Rewind(Stream stream) {
+ if (stream.CanSeek) stream.Position = 0;
+ }
+}
diff --git a/Compression.Lib/AudioFormatAdapters.cs b/Compression.Lib/AudioFormatAdapters.cs
new file mode 100644
index 000000000..5a6b97d8e
--- /dev/null
+++ b/Compression.Lib/AudioFormatAdapters.cs
@@ -0,0 +1,592 @@
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.Text;
+using Codec.ALaw;
+using Codec.ImaAdpcm;
+using Codec.Mp3;
+using Codec.MuLaw;
+using Codec.WavPack;
+using Compression.Registry;
+using FileFormat.Aiff;
+using FileFormat.Au;
+
+namespace Compression.Lib;
+
+///
+/// Non-invasive adapters for established format descriptors that already have
+/// codec read/write implementations but do not yet implement the common audio
+/// conversion interfaces directly.
+///
+internal static class AudioFormatAdapters {
+ private static readonly AiffAdapter Aiff = new();
+ private static readonly AuAdapter Au = new();
+ private static readonly Mp3Adapter Mp3 = new();
+ private static readonly WavPackAdapter WavPack = new();
+
+ public static IAudioPcmSource? ResolvePcmSource(IFormatDescriptor descriptor)
+ => descriptor as IAudioPcmSource ?? descriptor.Id switch {
+ "Aiff" => Aiff,
+ "Au" => Au,
+ "Mp3" => Mp3,
+ "WavPack" => WavPack,
+ _ => null,
+ };
+
+ public static IAudioPcmTarget? ResolvePcmTarget(IFormatDescriptor descriptor)
+ => descriptor as IAudioPcmTarget ?? descriptor.Id switch {
+ "Aiff" => Aiff,
+ "Au" => Au,
+ "Mp3" => Mp3,
+ "WavPack" => WavPack,
+ _ => null,
+ };
+
+ private sealed class AiffAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["pcm", "sowt", "mulaw", "alaw", "ima4", "fl32", "fl64"];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var materialized = Materialize(input);
+ var parsed = new AiffReader().Read(materialized.ToArray());
+ var compression = parsed.CompressionId;
+ if (!parsed.IsAifc || compression is "NONE" or "twos")
+ return DecodeAiffInteger(parsed, parsed.BitsPerSample, bigEndian: true);
+ return compression switch {
+ "sowt" => DecodeAiffInteger(parsed, parsed.BitsPerSample, bigEndian: false),
+ "ulaw" or "ULAW" => DecodeAiffCompanded(parsed, MuLawCodec.Decode(parsed.SoundData)),
+ "alaw" or "ALAW" => DecodeAiffCompanded(parsed, ALawCodec.Decode(parsed.SoundData)),
+ "ima4" => DecodeIma4(parsed),
+ "fl32" or "FL32" => new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 32, AudioPcmEncoding.IeeeFloat),
+ SwapSampleEndianness(parsed.SoundData, 4)),
+ "fl64" or "FL64" => new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 64, AudioPcmEncoding.IeeeFloat),
+ SwapSampleEndianness(parsed.SoundData, 8)),
+ _ => throw new NotSupportedException($"AIFC compression '{compression}' is not supported by the canonical PCM pipeline."),
+ };
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"AIFF/AIFC does not support codec '{codecId}' in this writer";
+ return false;
+ }
+ if (format.Channels < 1 || format.SampleRate < 1) {
+ reason = "AIFF/AIFC requires a positive sample rate and at least one channel";
+ return false;
+ }
+
+ if (codecId.Equals("mulaw", StringComparison.OrdinalIgnoreCase) ||
+ codecId.Equals("alaw", StringComparison.OrdinalIgnoreCase) ||
+ codecId.Equals("ima4", StringComparison.OrdinalIgnoreCase)) {
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = $"{codecId} AIFC encoding requires signed PCM16 input";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ if (codecId.Equals("fl32", StringComparison.OrdinalIgnoreCase)) {
+ reason = format.Encoding == AudioPcmEncoding.IeeeFloat && format.BitsPerSample == 32
+ ? null : "fl32 requires 32-bit IEEE-float PCM";
+ return reason is null;
+ }
+ if (codecId.Equals("fl64", StringComparison.OrdinalIgnoreCase)) {
+ reason = format.Encoding == AudioPcmEncoding.IeeeFloat && format.BitsPerSample == 64
+ ? null : "fl64 requires 64-bit IEEE-float PCM";
+ return reason is null;
+ }
+
+ if (format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ reason = "floating-point PCM must select fl32 or fl64";
+ return false;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32)) {
+ reason = "AIFF integer PCM supports 8/16/24/32 bits";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ var normalizedCodec = codecId.ToLowerInvariant();
+ var sampleFrames = checked((uint)pcm.FrameCount);
+ byte[] payload;
+ string compressionId;
+ string compressionName;
+ var bitsPerSample = pcm.Format.BitsPerSample;
+ var aifc = true;
+
+ switch (normalizedCodec) {
+ case "pcm":
+ compressionId = "NONE";
+ compressionName = "not compressed";
+ aifc = false;
+ payload = PrepareSignedEightOrEndianSwap(pcm, bigEndian: true);
+ break;
+ case "sowt":
+ compressionId = "sowt";
+ compressionName = "Little-endian PCM";
+ payload = PrepareSignedEightOrEndianSwap(pcm, bigEndian: false);
+ break;
+ case "mulaw":
+ compressionId = "ulaw";
+ compressionName = "mu-law 2:1";
+ bitsPerSample = 16;
+ payload = MuLawCodec.Encode(ReadPcm16(pcm.InterleavedData));
+ break;
+ case "alaw":
+ compressionId = "alaw";
+ compressionName = "A-law 2:1";
+ bitsPerSample = 16;
+ payload = ALawCodec.Encode(ReadPcm16(pcm.InterleavedData));
+ break;
+ case "ima4":
+ compressionId = "ima4";
+ compressionName = "IMA 4:1";
+ bitsPerSample = 16;
+ payload = ImaAdpcmCodec.EncodeQuickTime(ReadPcm16(pcm.InterleavedData), pcm.Format.Channels);
+ break;
+ case "fl32":
+ compressionId = "fl32";
+ compressionName = "32-bit floating point";
+ bitsPerSample = 32;
+ payload = SwapSampleEndianness(pcm.InterleavedData, 4);
+ break;
+ case "fl64":
+ compressionId = "fl64";
+ compressionName = "64-bit floating point";
+ bitsPerSample = 64;
+ payload = SwapSampleEndianness(pcm.InterleavedData, 8);
+ break;
+ default:
+ throw new UnreachableException();
+ }
+
+ WriteAiff(
+ output,
+ aifc,
+ pcm.Format.Channels,
+ pcm.Format.SampleRate,
+ bitsPerSample,
+ sampleFrames,
+ compressionId,
+ compressionName,
+ payload);
+ }
+
+ private static AudioPcmBuffer DecodeAiffInteger(AiffReader.ParsedAiff parsed, int bitsPerSample, bool bigEndian) {
+ var data = bigEndian && bitsPerSample > 8
+ ? SwapSampleEndianness(parsed.SoundData, bitsPerSample / 8)
+ : (byte[])parsed.SoundData.Clone();
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, bitsPerSample, AudioPcmEncoding.SignedInteger),
+ data);
+ }
+
+ private static AudioPcmBuffer DecodeAiffCompanded(AiffReader.ParsedAiff parsed, short[] samples) {
+ var bytes = new byte[samples.Length * 2];
+ for (var i = 0; i < samples.Length; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * 2, 2), samples[i]);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 16, AudioPcmEncoding.SignedInteger),
+ bytes);
+ }
+
+ private static AudioPcmBuffer DecodeIma4(AiffReader.ParsedAiff parsed) {
+ var channels = ImaAdpcmCodec.DecodeQuickTime(parsed.SoundData, parsed.NumChannels);
+ var availableFrames = channels.Length == 0 ? 0 : channels.Min(static channel => channel.Length);
+ var frames = parsed.SampleFrames > 0 ? Math.Min(parsed.SampleFrames, availableFrames) : availableFrames;
+ var bytes = new byte[checked(frames * parsed.NumChannels * 2)];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < parsed.NumChannels; ++channel)
+ BinaryPrimitives.WriteInt16LittleEndian(
+ bytes.AsSpan((frame * parsed.NumChannels + channel) * 2, 2),
+ channels[channel][frame]);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 16, AudioPcmEncoding.SignedInteger),
+ bytes);
+ }
+
+ private static byte[] PrepareSignedEightOrEndianSwap(AudioPcmBuffer pcm, bool bigEndian) {
+ var payload = (byte[])pcm.InterleavedData.Clone();
+ if (pcm.Format.BitsPerSample == 8) {
+ if (pcm.Format.Encoding == AudioPcmEncoding.UnsignedInteger)
+ for (var i = 0; i < payload.Length; ++i) payload[i] ^= 0x80;
+ return payload;
+ }
+ return bigEndian ? SwapSampleEndianness(payload, pcm.Format.BytesPerSample) : payload;
+ }
+
+ private static void WriteAiff(
+ Stream output,
+ bool aifc,
+ int channels,
+ int sampleRate,
+ int bitsPerSample,
+ uint sampleFrames,
+ string compressionId,
+ string compressionName,
+ byte[] payload
+ ) {
+ using var commBody = new MemoryStream();
+ Span fixedComm = stackalloc byte[18];
+ BinaryPrimitives.WriteInt16BigEndian(fixedComm, checked((short)channels));
+ BinaryPrimitives.WriteUInt32BigEndian(fixedComm[2..], sampleFrames);
+ BinaryPrimitives.WriteInt16BigEndian(fixedComm[6..], checked((short)bitsPerSample));
+ AiffWriter.Encode80BitFloat(sampleRate).CopyTo(fixedComm[8..]);
+ commBody.Write(fixedComm);
+
+ if (aifc) {
+ if (compressionId.Length != 4)
+ throw new ArgumentException("AIFC compression IDs must be exactly four characters.", nameof(compressionId));
+ commBody.Write(Encoding.ASCII.GetBytes(compressionId));
+ var nameBytes = Encoding.ASCII.GetBytes(compressionName);
+ var nameLength = Math.Min(byte.MaxValue, nameBytes.Length);
+ commBody.WriteByte((byte)nameLength);
+ commBody.Write(nameBytes, 0, nameLength);
+ }
+
+ var comm = WrapIffChunk("COMM", commBody.ToArray());
+ var ssndBody = new byte[8 + payload.Length];
+ payload.CopyTo(ssndBody.AsSpan(8));
+ var ssnd = WrapIffChunk("SSND", ssndBody);
+ var fver = aifc ? WrapIffChunk("FVER", [0xA2, 0x80, 0x51, 0x40]) : [];
+ var formType = aifc ? "AIFC"u8 : "AIFF"u8;
+ var formSize = checked(4 + fver.Length + comm.Length + ssnd.Length);
+
+ Span header = stackalloc byte[12];
+ "FORM"u8.CopyTo(header);
+ BinaryPrimitives.WriteUInt32BigEndian(header[4..], checked((uint)formSize));
+ formType.CopyTo(header[8..]);
+ output.Write(header);
+ if (fver.Length != 0) output.Write(fver);
+ output.Write(comm);
+ output.Write(ssnd);
+ }
+
+ private static byte[] WrapIffChunk(string id, byte[] body) {
+ var paddedLength = body.Length + (body.Length & 1);
+ var chunk = new byte[8 + paddedLength];
+ Encoding.ASCII.GetBytes(id).CopyTo(chunk, 0);
+ BinaryPrimitives.WriteUInt32BigEndian(chunk.AsSpan(4), checked((uint)body.Length));
+ body.CopyTo(chunk.AsSpan(8));
+ return chunk;
+ }
+ }
+
+ private sealed class AuAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["pcm", "mulaw", "alaw"];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var materialized = Materialize(input);
+ var parsed = new AuReader().Read(materialized.ToArray());
+ return parsed.Encoding switch {
+ 1 => DecodeAuCompanded(parsed, MuLawCodec.Decode(parsed.SoundData)),
+ 2 => new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 8, AudioPcmEncoding.SignedInteger),
+ (byte[])parsed.SoundData.Clone()),
+ 3 => DecodeBigEndianInteger(parsed, 16),
+ 4 => DecodeBigEndianInteger(parsed, 24),
+ 5 => DecodeBigEndianInteger(parsed, 32),
+ 6 => DecodeBigEndianFloat(parsed, 32),
+ 7 => DecodeBigEndianFloat(parsed, 64),
+ 27 => DecodeAuCompanded(parsed, ALawCodec.Decode(parsed.SoundData)),
+ _ => throw new NotSupportedException($"AU encoding {parsed.Encoding} is not supported by the canonical PCM pipeline."),
+ };
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"AU does not support codec '{codecId}' in this writer";
+ return false;
+ }
+ if (format.Channels < 1 || format.SampleRate < 1) {
+ reason = "AU requires a positive sample rate and at least one channel";
+ return false;
+ }
+ if (codecId.Equals("mulaw", StringComparison.OrdinalIgnoreCase) ||
+ codecId.Equals("alaw", StringComparison.OrdinalIgnoreCase)) {
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "G.711 AU encoding requires signed PCM16 input";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ if (format.BitsPerSample is not (32 or 64)) {
+ reason = "AU floating-point PCM supports 32 or 64 bits";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32)) {
+ reason = "AU integer PCM supports 8/16/24/32 bits";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ uint encoding;
+ byte[] payload;
+ if (codecId.Equals("mulaw", StringComparison.OrdinalIgnoreCase)) {
+ encoding = 1;
+ payload = MuLawCodec.Encode(ReadPcm16(pcm.InterleavedData));
+ } else if (codecId.Equals("alaw", StringComparison.OrdinalIgnoreCase)) {
+ encoding = 27;
+ payload = ALawCodec.Encode(ReadPcm16(pcm.InterleavedData));
+ } else if (pcm.Format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ encoding = pcm.Format.BitsPerSample == 32 ? 6u : 7u;
+ payload = SwapSampleEndianness(pcm.InterleavedData, pcm.Format.BytesPerSample);
+ } else if (pcm.Format.BitsPerSample == 8) {
+ encoding = 2;
+ payload = (byte[])pcm.InterleavedData.Clone();
+ if (pcm.Format.Encoding == AudioPcmEncoding.UnsignedInteger)
+ for (var i = 0; i < payload.Length; ++i) payload[i] ^= 0x80;
+ } else {
+ encoding = pcm.Format.BitsPerSample switch {
+ 16 => 3u,
+ 24 => 4u,
+ 32 => 5u,
+ _ => throw new UnreachableException(),
+ };
+ payload = SwapSampleEndianness(pcm.InterleavedData, pcm.Format.BytesPerSample);
+ }
+
+ WriteAu(output, encoding, pcm.Format.SampleRate, pcm.Format.Channels, payload);
+ }
+
+ private static AudioPcmBuffer DecodeAuCompanded(AuReader.ParsedAu parsed, short[] samples) {
+ var bytes = new byte[samples.Length * 2];
+ for (var i = 0; i < samples.Length; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * 2, 2), samples[i]);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, 16, AudioPcmEncoding.SignedInteger),
+ bytes);
+ }
+
+ private static AudioPcmBuffer DecodeBigEndianInteger(AuReader.ParsedAu parsed, int bitsPerSample)
+ => new(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, bitsPerSample, AudioPcmEncoding.SignedInteger),
+ SwapSampleEndianness(parsed.SoundData, bitsPerSample / 8));
+
+ private static AudioPcmBuffer DecodeBigEndianFloat(AuReader.ParsedAu parsed, int bitsPerSample)
+ => new(
+ new AudioPcmFormat(parsed.SampleRate, parsed.NumChannels, bitsPerSample, AudioPcmEncoding.IeeeFloat),
+ SwapSampleEndianness(parsed.SoundData, bitsPerSample / 8));
+
+ private static void WriteAu(Stream output, uint encoding, int sampleRate, int channels, byte[] payload) {
+ Span header = stackalloc byte[24];
+ ".snd"u8.CopyTo(header);
+ BinaryPrimitives.WriteUInt32BigEndian(header[4..], 24);
+ BinaryPrimitives.WriteUInt32BigEndian(header[8..], checked((uint)payload.Length));
+ BinaryPrimitives.WriteUInt32BigEndian(header[12..], encoding);
+ BinaryPrimitives.WriteUInt32BigEndian(header[16..], checked((uint)sampleRate));
+ BinaryPrimitives.WriteUInt32BigEndian(header[20..], checked((uint)channels));
+ output.Write(header);
+ output.Write(payload);
+ }
+ }
+
+ private sealed class Mp3Adapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["mp3", "mpeg-layer3"];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var materialized = Materialize(input);
+ var info = Mp3Codec.ReadStreamInfo(materialized);
+ materialized.Position = 0;
+ using var pcm = new MemoryStream();
+ Mp3Codec.Decompress(materialized, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(info.SampleRate, info.Channels, 16, AudioPcmEncoding.SignedInteger),
+ pcm.ToArray());
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not MPEG Layer III";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "MP3 encoding requires signed PCM16 input";
+ return false;
+ }
+ if (format.Channels is < 1 or > 2) {
+ reason = "MP3 supports mono or stereo input";
+ return false;
+ }
+ if (format.SampleRate is < 8_000 or > 48_000) {
+ reason = "MP3 input sample rate must be between 8 and 48 kHz";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+ if ((pcm.InterleavedData.Length & 1) != 0)
+ throw new InvalidDataException("PCM16 payload has an odd byte length.");
+
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var bitrate = options.GetOptionInt("bitrate", 128);
+ if (bitrate > 1_000) bitrate = (bitrate + 500) / 1_000;
+ var quality = options.GetOptionInt("quality", options.Level ?? 5);
+ var variableBitrate = options.GetOptionBool("vbr", false);
+ var outputRate = options.HasOption("sample-rate") ? options.GetOptionInt("sample-rate", pcm.Format.SampleRate) : null;
+ var channelMode = options.GetOption("channel-mode", "auto").ToLowerInvariant() switch {
+ "stereo" => Mp3EncoderChannelMode.Stereo,
+ "joint" or "joint-stereo" or "jointstereo" => Mp3EncoderChannelMode.JointStereo,
+ "dual" or "dual-channel" => Mp3EncoderChannelMode.DualChannel,
+ "mono" => Mp3EncoderChannelMode.Mono,
+ _ => Mp3EncoderChannelMode.Auto,
+ };
+
+ var encoded = Mp3Encoder.Encode(samples, new Mp3EncoderOptions(
+ pcm.Format.SampleRate,
+ pcm.Format.Channels,
+ bitrate,
+ channelMode,
+ quality,
+ variableBitrate,
+ outputRate));
+ output.Write(encoded);
+ }
+ }
+
+ private sealed class WavPackAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["wavpack", "wv"];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var materialized = Materialize(input);
+ var info = WavPackCodec.ReadStreamInfo(materialized);
+ materialized.Position = 0;
+ using var pcm = new MemoryStream();
+ WavPackCodec.Decompress(materialized, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(
+ info.SampleRate,
+ info.Channels,
+ info.BitsPerSample,
+ info.IsFloat ? AudioPcmEncoding.IeeeFloat
+ : info.BitsPerSample == 8 ? AudioPcmEncoding.UnsignedInteger
+ : AudioPcmEncoding.SignedInteger),
+ pcm.ToArray());
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not WavPack";
+ return false;
+ }
+ if (format.Channels < 1) {
+ reason = "WavPack requires at least one channel";
+ return false;
+ }
+ if (format.SampleRate < 1) {
+ reason = "WavPack requires a positive sample rate";
+ return false;
+ }
+ if (format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ if (format.BitsPerSample != 32) {
+ reason = "WavPack floating-point input must be 32-bit IEEE float";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32)) {
+ reason = "WavPack integer input supports 8/16/24/32-bit PCM";
+ return false;
+ }
+ if (format.BitsPerSample == 8 && format.Encoding != AudioPcmEncoding.UnsignedInteger) {
+ reason = "8-bit PCM must use unsigned WAV/WavPack sample representation";
+ return false;
+ }
+ if (format.BitsPerSample > 8 && format.Encoding != AudioPcmEncoding.SignedInteger) {
+ reason = "16/24/32-bit WavPack integer input must be signed PCM";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ using var source = new MemoryStream(pcm.InterleavedData, writable: false);
+ WavPackCodec.Compress(
+ source,
+ output,
+ pcm.Format.Channels,
+ pcm.Format.SampleRate,
+ pcm.Format.BitsPerSample,
+ isFloat: pcm.Format.Encoding == AudioPcmEncoding.IeeeFloat);
+ }
+ }
+
+ private static short[] ReadPcm16(ReadOnlySpan data) {
+ if ((data.Length & 1) != 0)
+ throw new InvalidDataException("PCM16 payload has an odd byte length.");
+ var samples = new short[data.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
+ return samples;
+ }
+
+ private static byte[] SwapSampleEndianness(ReadOnlySpan data, int bytesPerSample) {
+ if (bytesPerSample <= 1) return data.ToArray();
+ if (data.Length % bytesPerSample != 0)
+ throw new InvalidDataException("PCM payload length is not aligned to the sample width.");
+ var result = new byte[data.Length];
+ for (var offset = 0; offset < data.Length; offset += bytesPerSample)
+ for (var i = 0; i < bytesPerSample; ++i)
+ result[offset + i] = data[offset + bytesPerSample - 1 - i];
+ return result;
+ }
+
+ private static MemoryStream Materialize(Stream input) {
+ if (input.CanSeek) input.Position = 0;
+ var memory = new MemoryStream();
+ input.CopyTo(memory);
+ memory.Position = 0;
+ return memory;
+ }
+}
diff --git a/Compression.Lib/CafAudioAdapter.cs b/Compression.Lib/CafAudioAdapter.cs
new file mode 100644
index 000000000..8827f8ee5
--- /dev/null
+++ b/Compression.Lib/CafAudioAdapter.cs
@@ -0,0 +1,187 @@
+using System.Buffers.Binary;
+using Codec.ALaw;
+using Codec.ImaAdpcm;
+using Codec.MuLaw;
+using Compression.Registry;
+using FileFormat.Caf;
+
+namespace Compression.Lib;
+
+/// Canonical PCM/G.711/QuickTime-IMA adapter for Apple Core Audio Format.
+internal sealed class CafAudioAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private const uint FlagIsFloat = 0x1;
+ private const uint FlagIsSignedInteger = 0x4;
+ private const uint FlagIsPacked = 0x8;
+ private const int Ima4PacketBytesPerChannel = 34;
+ private const int Ima4FramesPerPacket = 64;
+ private static readonly string[] Codecs = ["lpcm", "pcm", "float", "mulaw", "alaw", "ima4"];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ var parsed = new CafReader().Read(memory.ToArray());
+ if (parsed.FormatId != "lpcm")
+ throw new NotSupportedException($"CAF codec '{parsed.FormatId}' is not decoded by this adapter.");
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(
+ parsed.SampleRate,
+ parsed.NumChannels,
+ parsed.BitsPerSample,
+ parsed.IsFloat ? AudioPcmEncoding.IeeeFloat : AudioPcmEncoding.SignedInteger,
+ parsed.ChannelMask),
+ parsed.InterleavedPcm);
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"CAF codec '{codecId}' is not supported by this writer";
+ return false;
+ }
+ if (format.Channels < 1 || format.SampleRate < 1) {
+ reason = "CAF requires a positive sample rate and at least one channel";
+ return false;
+ }
+ var codec = codecId.ToLowerInvariant();
+ if (codec == "ima4") {
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "CAF ima4 encoding requires signed PCM16 input";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (codec is "mulaw" or "alaw") {
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "CAF G.711 encoding requires signed PCM16 input";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (codec == "float") {
+ reason = format.Encoding == AudioPcmEncoding.IeeeFloat && format.BitsPerSample is 32 or 64
+ ? null : "CAF float encoding requires 32- or 64-bit IEEE-float PCM";
+ return reason is null;
+ }
+ if (format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ reason = "floating-point PCM must select the 'float' CAF codec";
+ return false;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32)) {
+ reason = "CAF integer LPCM supports 8/16/24/32 bits";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ switch (codecId.ToLowerInvariant()) {
+ case "lpcm":
+ case "pcm":
+ WriteCaf(output, pcm.Format.SampleRate, pcm.Format.Channels, "lpcm", FlagIsSignedInteger | FlagIsPacked,
+ checked((uint)pcm.Format.BytesPerFrame), 1, checked((uint)pcm.Format.BitsPerSample), NormalizeSignedEightBit(pcm));
+ break;
+ case "float":
+ WriteCaf(output, pcm.Format.SampleRate, pcm.Format.Channels, "lpcm", FlagIsFloat | FlagIsPacked,
+ checked((uint)pcm.Format.BytesPerFrame), 1, checked((uint)pcm.Format.BitsPerSample), pcm.InterleavedData);
+ break;
+ case "mulaw":
+ WriteG711(output, pcm, aLaw: false);
+ break;
+ case "alaw":
+ WriteG711(output, pcm, aLaw: true);
+ break;
+ case "ima4":
+ WriteIma4(output, pcm);
+ break;
+ }
+ }
+
+ private static void WriteIma4(Stream output, AudioPcmBuffer pcm) {
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var payload = ImaAdpcmCodec.EncodeQuickTime(samples, pcm.Format.Channels);
+ var packetBytes = checked(Ima4PacketBytesPerChannel * pcm.Format.Channels);
+ var packetCount = payload.Length / packetBytes;
+ var validFrames = pcm.FrameCount;
+ var codedFrames = checked((long)packetCount * Ima4FramesPerPacket);
+ var remainderFrames = checked((int)(codedFrames - validFrames));
+ WriteCaf(output, pcm.Format.SampleRate, pcm.Format.Channels, "ima4", 0,
+ checked((uint)packetBytes), Ima4FramesPerPacket, 0, payload,
+ packetCount, validFrames, remainderFrames);
+ }
+
+ private static void WriteG711(Stream output, AudioPcmBuffer pcm, bool aLaw) {
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var payload = aLaw ? ALawCodec.Encode(samples) : MuLawCodec.Encode(samples);
+ WriteCaf(output, pcm.Format.SampleRate, pcm.Format.Channels, aLaw ? "alaw" : "ulaw", 0,
+ checked((uint)pcm.Format.Channels), 1, 8, payload);
+ }
+
+ private static void WriteCaf(Stream output, int sampleRate, int channels, string formatId,
+ uint formatFlags, uint bytesPerPacket, uint framesPerPacket, uint bitsPerChannel, ReadOnlySpan payload,
+ long? packetCount = null, long? validFrames = null, int remainderFrames = 0) {
+ Span header = stackalloc byte[8];
+ "caff"u8.CopyTo(header);
+ BinaryPrimitives.WriteUInt16BigEndian(header[4..], 1);
+ BinaryPrimitives.WriteUInt16BigEndian(header[6..], 0);
+ output.Write(header);
+
+ Span desc = stackalloc byte[32];
+ BinaryPrimitives.WriteDoubleBigEndian(desc, sampleRate);
+ System.Text.Encoding.ASCII.GetBytes(formatId, desc[8..12]);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[12..], formatFlags);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[16..], bytesPerPacket);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[20..], framesPerPacket);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[24..], checked((uint)channels));
+ BinaryPrimitives.WriteUInt32BigEndian(desc[28..], bitsPerChannel);
+ WriteChunk(output, "desc"u8, desc);
+
+ if (packetCount is { } packets && validFrames is { } frames) {
+ Span pakt = stackalloc byte[24];
+ BinaryPrimitives.WriteInt64BigEndian(pakt, packets);
+ BinaryPrimitives.WriteInt64BigEndian(pakt[8..], frames);
+ BinaryPrimitives.WriteInt32BigEndian(pakt[16..], 0);
+ BinaryPrimitives.WriteInt32BigEndian(pakt[20..], remainderFrames);
+ WriteChunk(output, "pakt"u8, pakt);
+ }
+
+ var data = new byte[4 + payload.Length];
+ payload.CopyTo(data.AsSpan(4));
+ WriteChunk(output, "data"u8, data);
+ }
+
+ private static void WriteChunk(Stream output, ReadOnlySpan type, ReadOnlySpan body) {
+ if (type.Length != 4) throw new ArgumentException("CAF chunk type must be four bytes.", nameof(type));
+ Span header = stackalloc byte[12];
+ type.CopyTo(header);
+ BinaryPrimitives.WriteInt64BigEndian(header[4..], body.Length);
+ output.Write(header);
+ output.Write(body);
+ }
+
+ private static byte[] NormalizeSignedEightBit(AudioPcmBuffer pcm) {
+ var data = (byte[])pcm.InterleavedData.Clone();
+ if (pcm.Format.BitsPerSample == 8 && pcm.Format.Encoding == AudioPcmEncoding.UnsignedInteger)
+ for (var i = 0; i < data.Length; ++i) data[i] ^= 0x80;
+ return data;
+ }
+
+ private static short[] ReadPcm16(ReadOnlySpan data) {
+ if ((data.Length & 1) != 0) throw new InvalidDataException("PCM16 payload has odd length.");
+ var samples = new short[data.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
+ return samples;
+ }
+}
diff --git a/Compression.Lib/ElementaryAudioAdapters.cs b/Compression.Lib/ElementaryAudioAdapters.cs
new file mode 100644
index 000000000..ab8e12627
--- /dev/null
+++ b/Compression.Lib/ElementaryAudioAdapters.cs
@@ -0,0 +1,181 @@
+using System.Buffers.Binary;
+using Codec.Ac3;
+using Codec.Dts;
+using Compression.Registry;
+
+namespace Compression.Lib;
+
+internal sealed class Ac3AudioAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["ac3"];
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var source = ElementaryAudioAdapterHelpers.Materialize(input);
+ var info = Ac3Codec.ReadStreamInfo(source);
+ source.Position = 0;
+ using var pcm = new MemoryStream();
+ Ac3Codec.Decompress(source, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(info.SampleRate, info.Channels, 16, AudioPcmEncoding.SignedInteger),
+ pcm.ToArray());
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!codecId.Equals("ac3", StringComparison.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not AC-3";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "AC-3 encoding requires signed PCM16 input";
+ return false;
+ }
+ if (format.SampleRate is not (32_000 or 44_100 or 48_000)) {
+ reason = "AC-3 encoding supports 32, 44.1, or 48 kHz";
+ return false;
+ }
+ if (format.Channels is < 1 or > 6) {
+ reason = "AC-3 encoding supports 1 to 6 channels";
+ return false;
+ }
+ if (!TryResolveLayout(format.Channels, options, out _, out _, out reason)) return false;
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ _ = TryResolveLayout(pcm.Format.Channels, options, out var acmod, out var lfe, out _);
+ var samples = ElementaryAudioAdapterHelpers.ReadPcm16(pcm.InterleavedData);
+ var bitrate = options.GetOptionInt("bitrate", pcm.Format.Channels switch {
+ 1 => 96_000,
+ 2 => 192_000,
+ 3 => 256_000,
+ 4 => 384_000,
+ _ => 448_000,
+ });
+ if (bitrate < 1_000) bitrate *= 1_000;
+
+ var encoded = Ac3Codec.Encode(samples, new Ac3EncoderOptions(
+ pcm.Format.SampleRate,
+ bitrate,
+ acmod,
+ lfe,
+ options.GetOptionInt("dialnorm", -31),
+ options.GetOptionInt("cutoff", 0),
+ PadFinalFrame: options.GetOptionBool("pad-final-frame", true)));
+ output.Write(encoded);
+ }
+
+ private static bool TryResolveLayout(int channels, FormatCreateOptions options,
+ out int acmod, out bool lfe, out string? reason) {
+ if (options.TryGetInt("acmod", out var requestedAcmod)) {
+ acmod = requestedAcmod;
+ lfe = options.GetOptionBool("lfe", false);
+ var expected = AcmodChannels(acmod) + (lfe ? 1 : 0);
+ if (expected != channels) {
+ reason = $"acmod={acmod}, lfe={lfe} expects {expected} channels, input has {channels}";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ (acmod, lfe) = channels switch {
+ 1 => (1, false),
+ 2 => (2, false),
+ 3 => (3, false),
+ 4 => (6, false),
+ 5 => (7, false),
+ 6 => (7, true),
+ _ => (0, false),
+ };
+ reason = acmod == 0 ? $"no default AC-3 layout for {channels} channels" : null;
+ return acmod != 0;
+ }
+
+ private static int AcmodChannels(int acmod) => acmod switch {
+ 0 => 2,
+ 1 => 1,
+ 2 => 2,
+ 3 or 4 => 3,
+ 5 or 6 => 4,
+ 7 => 5,
+ _ => 0,
+ };
+}
+
+internal sealed class DtsAudioAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["dts"];
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ using var source = ElementaryAudioAdapterHelpers.Materialize(input);
+ var info = DtsCodec.ReadStreamInfo(source);
+ source.Position = 0;
+ using var pcm = new MemoryStream();
+ DtsCodec.Decompress(source, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(info.SampleRate, info.Channels, 16, AudioPcmEncoding.SignedInteger),
+ pcm.ToArray());
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!codecId.Equals("dts", StringComparison.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not DTS core";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "DTS core encoding requires signed PCM16 input";
+ return false;
+ }
+ if (format.Channels is not (1 or 2 or 4 or 5)) {
+ reason = "DTS core encoder supports mono, stereo, quad, or 5.0";
+ return false;
+ }
+ if (format.SampleRate is < 8_000 or > 48_000) {
+ reason = "DTS core sample rate must be between 8 and 48 kHz";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+ var samples = ElementaryAudioAdapterHelpers.ReadPcm16(pcm.InterleavedData);
+ var bitrate = options.GetOptionInt("bitrate", pcm.Format.Channels <= 2 ? 768_000 : 1_536_000);
+ if (bitrate < 10_000) bitrate *= 1_000;
+ var encoded = DtsCodec.Encode(samples, new DtsEncoderOptions(
+ pcm.Format.SampleRate,
+ pcm.Format.Channels,
+ bitrate,
+ options.GetOptionInt("subbands", 16),
+ options.GetOptionBool("pad-final-frame", true)));
+ output.Write(encoded);
+ }
+}
+
+file static class ElementaryAudioAdapterHelpers {
+ public static short[] ReadPcm16(ReadOnlySpan data) {
+ if ((data.Length & 1) != 0) throw new InvalidDataException("PCM16 payload has odd length.");
+ var samples = new short[data.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
+ return samples;
+ }
+
+ public static MemoryStream Materialize(Stream input) {
+ if (input.CanSeek) input.Position = 0;
+ var memory = new MemoryStream();
+ input.CopyTo(memory);
+ memory.Position = 0;
+ return memory;
+ }
+}
diff --git a/Compression.Lib/Mp3AudioPacketAdapter.cs b/Compression.Lib/Mp3AudioPacketAdapter.cs
new file mode 100644
index 000000000..08cf3948f
--- /dev/null
+++ b/Compression.Lib/Mp3AudioPacketAdapter.cs
@@ -0,0 +1,210 @@
+using System.Buffers.Binary;
+using Compression.Registry;
+
+namespace Compression.Lib;
+
+///
+/// Packet-preserving MPEG audio adapter. Frame parsing follows ISO/IEC 11172-3 and ISO/IEC 13818-3;
+/// ID3 metadata is intentionally outside the encoded packet stream.
+///
+internal sealed class Mp3AudioPacketAdapter : IAudioDemuxSource, IAudioMuxTarget {
+ internal static readonly Mp3AudioPacketAdapter Instance = new();
+
+ private static readonly string[] MuxCodecs = ["mp3", "mp2"];
+ private static readonly int[] Mpeg1Layer1Bitrates = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448];
+ private static readonly int[] Mpeg1Layer2Bitrates = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384];
+ private static readonly int[] Mpeg1Layer3Bitrates = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
+ private static readonly int[] Mpeg2Layer1Bitrates = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256];
+ private static readonly int[] Mpeg2Layer23Bitrates = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
+ private static readonly int[] Mpeg1SampleRates = [44_100, 48_000, 32_000];
+
+ public IReadOnlyList SupportedMuxCodecs => MuxCodecs;
+
+ public bool TryDemux(Stream input, out AudioEncodedStream? stream) {
+ ArgumentNullException.ThrowIfNull(input);
+ var bytes = Materialize(input);
+ stream = null;
+ if (bytes.Length < 4) return false;
+
+ var offset = SkipId3v2(bytes);
+ if (offset == bytes.Length)
+ throw new InvalidDataException("MPEG audio stream contains metadata but no audio frames.");
+ if (offset + 4 > bytes.Length || !TryParseHeader(bytes.AsSpan(offset), out var first))
+ return false;
+ EnsureSupportedLayer(first.Layer);
+
+ var packets = new List();
+ while (offset < bytes.Length) {
+ if (IsId3v1(bytes, offset)) {
+ offset += 128;
+ break;
+ }
+ if (offset + 4 > bytes.Length)
+ throw new InvalidDataException($"Truncated MPEG audio frame header at byte offset {offset}.");
+ if (!TryParseHeader(bytes.AsSpan(offset), out var header))
+ throw new InvalidDataException($"Invalid MPEG audio frame header at byte offset {offset}.");
+ EnsureSupportedLayer(header.Layer);
+ if (header.Version != first.Version || header.Layer != first.Layer ||
+ header.SampleRate != first.SampleRate || header.Channels != first.Channels)
+ throw new InvalidDataException("MPEG audio stream changes version, layer, sample rate, or channel count between frames.");
+ if (offset + header.FrameSize > bytes.Length)
+ throw new InvalidDataException($"Truncated MPEG audio frame at byte offset {offset}: expected {header.FrameSize} bytes.");
+
+ packets.Add(new AudioPacket(bytes.AsSpan(offset, header.FrameSize).ToArray(), header.SamplesPerFrame));
+ offset += header.FrameSize;
+ }
+
+ if (offset != bytes.Length)
+ throw new InvalidDataException($"Unexpected trailing data after MPEG audio frames at byte offset {offset}.");
+ if (packets.Count == 0)
+ throw new InvalidDataException("MPEG audio stream contains no complete frames.");
+
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) {
+ ["mpeg-version"] = VersionName(first.Version),
+ ["mpeg-layer"] = first.Layer.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["channel-mode"] = ChannelModeName(first.ChannelMode),
+ ["samples-per-frame"] = first.SamplesPerFrame.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ };
+ stream = new AudioEncodedStream(
+ new AudioStreamFormat(first.Layer == 3 ? "mp3" : "mp2", first.SampleRate, first.Channels, Properties: properties),
+ packets);
+ return true;
+ }
+
+ public bool CanMux(AudioStreamFormat stream, FormatCreateOptions options, out string? reason) {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!MuxCodecs.Contains(stream.CodecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"raw MPEG audio accepts Layer II/III packets, not codec '{stream.CodecId}'";
+ return false;
+ }
+ if (stream.SampleRate <= 0 || stream.Channels is < 1 or > 2) {
+ reason = "raw MPEG audio requires a positive sample rate and mono/stereo packets";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void Mux(Stream output, AudioEncodedStream stream, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanMux(stream.Format, options, out var reason))
+ throw new NotSupportedException(reason);
+ if (stream.Packets.Count == 0)
+ throw new ArgumentException("MPEG audio muxing requires at least one frame.", nameof(stream));
+
+ var expectedLayer = stream.Format.CodecId.Equals("mp3", StringComparison.OrdinalIgnoreCase) ? 3 : 2;
+ foreach (var packet in stream.Packets) {
+ if (packet.IsHeader)
+ throw new InvalidDataException("Raw MPEG audio does not use out-of-band header packets.");
+ if (packet.Data.Length < 4 || !TryParseHeader(packet.Data, out var header))
+ throw new InvalidDataException("MPEG audio packet does not begin with a valid frame header.");
+ EnsureSupportedLayer(header.Layer);
+ if (header.Layer != expectedLayer)
+ throw new InvalidDataException($"MPEG audio packet is Layer {header.Layer}, but stream codec is '{stream.Format.CodecId}'.");
+ if (header.FrameSize != packet.Data.Length)
+ throw new InvalidDataException($"MPEG audio packet length {packet.Data.Length} does not match header frame size {header.FrameSize}.");
+ if (header.SampleRate != stream.Format.SampleRate || header.Channels != stream.Format.Channels)
+ throw new InvalidDataException("MPEG audio packet geometry does not match the advertised stream format.");
+ if (packet.DurationSamples > 0 && packet.DurationSamples != header.SamplesPerFrame)
+ throw new InvalidDataException("MPEG audio packet duration does not match its frame header.");
+ output.Write(packet.Data);
+ }
+ }
+
+ private static int SkipId3v2(ReadOnlySpan bytes) {
+ if (bytes.Length < 10 || !bytes[..3].SequenceEqual("ID3"u8)) return 0;
+ if ((bytes[6] | bytes[7] | bytes[8] | bytes[9]) >= 0x80)
+ throw new InvalidDataException("ID3v2 tag uses an invalid synchsafe size.");
+ var payloadSize = bytes[6] << 21 | bytes[7] << 14 | bytes[8] << 7 | bytes[9];
+ var footerSize = (bytes[5] & 0x10) != 0 ? 10 : 0;
+ var totalSize = checked(10 + payloadSize + footerSize);
+ if (totalSize > bytes.Length)
+ throw new InvalidDataException("Truncated ID3v2 tag precedes MPEG audio frames.");
+ return totalSize;
+ }
+
+ private static bool IsId3v1(ReadOnlySpan bytes, int offset)
+ => bytes.Length - offset == 128 && bytes.Slice(offset, 3).SequenceEqual("TAG"u8);
+
+ private static bool TryParseHeader(ReadOnlySpan bytes, out MpegFrameHeader header) {
+ header = default;
+ if (bytes.Length < 4) return false;
+ var word = BinaryPrimitives.ReadUInt32BigEndian(bytes);
+ if ((word & 0xFFE0_0000u) != 0xFFE0_0000u) return false;
+
+ var versionBits = (int)((word >> 19) & 0x3);
+ var layerBits = (int)((word >> 17) & 0x3);
+ var bitrateIndex = (int)((word >> 12) & 0xF);
+ var rateIndex = (int)((word >> 10) & 0x3);
+ var padding = (int)((word >> 9) & 0x1);
+ var channelMode = (int)((word >> 6) & 0x3);
+ if (versionBits == 1 || layerBits == 0 || bitrateIndex == 15 || rateIndex == 3) return false;
+ if (bitrateIndex == 0)
+ throw new NotSupportedException("Free-format MPEG audio frames are not packetized because their frame size is not self-describing.");
+
+ var version = versionBits switch { 3 => 1, 2 => 2, 0 => 25, _ => 0 };
+ var layer = 4 - layerBits;
+ var bitrateKbps = GetBitrateKbps(version, layer, bitrateIndex);
+ var sampleRate = Mpeg1SampleRates[rateIndex] / (version == 1 ? 1 : version == 2 ? 2 : 4);
+ var samplesPerFrame = layer switch {
+ 1 => 384,
+ 2 => 1152,
+ 3 when version == 1 => 1152,
+ 3 => 576,
+ _ => 0,
+ };
+ var frameSize = layer switch {
+ 1 => (12 * bitrateKbps * 1000 / sampleRate + padding) * 4,
+ 2 => 144 * bitrateKbps * 1000 / sampleRate + padding,
+ 3 when version == 1 => 144 * bitrateKbps * 1000 / sampleRate + padding,
+ 3 => 72 * bitrateKbps * 1000 / sampleRate + padding,
+ _ => 0,
+ };
+ if (frameSize < 4) return false;
+ header = new MpegFrameHeader(version, layer, sampleRate, channelMode == 3 ? 1 : 2, channelMode, samplesPerFrame, frameSize);
+ return true;
+ }
+
+ private static int GetBitrateKbps(int version, int layer, int index)
+ => (version, layer) switch {
+ (1, 1) => Mpeg1Layer1Bitrates[index],
+ (1, 2) => Mpeg1Layer2Bitrates[index],
+ (1, 3) => Mpeg1Layer3Bitrates[index],
+ (_, 1) => Mpeg2Layer1Bitrates[index],
+ _ => Mpeg2Layer23Bitrates[index],
+ };
+
+ private static void EnsureSupportedLayer(int layer) {
+ if (layer is not (2 or 3))
+ throw new NotSupportedException($"MPEG Layer {layer} packet routing is not exposed by the MP3/MP2 descriptor.");
+ }
+
+ private static string VersionName(int version)
+ => version == 25 ? "2.5" : version.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ private static string ChannelModeName(int channelMode) => channelMode switch {
+ 0 => "stereo",
+ 1 => "joint-stereo",
+ 2 => "dual-channel",
+ 3 => "mono",
+ _ => "unknown",
+ };
+
+ private static byte[] Materialize(Stream input) {
+ using var copy = new MemoryStream();
+ input.CopyTo(copy);
+ return copy.ToArray();
+ }
+
+ private readonly record struct MpegFrameHeader(
+ int Version,
+ int Layer,
+ int SampleRate,
+ int Channels,
+ int ChannelMode,
+ int SamplesPerFrame,
+ int FrameSize);
+}
diff --git a/Compression.Lib/WavAudioAdapter.cs b/Compression.Lib/WavAudioAdapter.cs
new file mode 100644
index 000000000..511df6629
--- /dev/null
+++ b/Compression.Lib/WavAudioAdapter.cs
@@ -0,0 +1,212 @@
+using System.Buffers.Binary;
+using Codec.ALaw;
+using Codec.ImaAdpcm;
+using Codec.MsAdpcm;
+using Codec.MuLaw;
+using Compression.Registry;
+using FileFormat.Wav;
+
+namespace Compression.Lib;
+
+/// Canonical PCM and compressed-audio writer adapter for RIFF/WAVE.
+internal sealed class WavAudioAdapter : IAudioPcmSource, IAudioPcmTarget {
+ private static readonly string[] Codecs = ["pcm", "float", "alaw", "mulaw", "ima-adpcm", "ms-adpcm"];
+ private static readonly short[] MsAdpcmCoefficients = [256, 0, 512, -256, 0, 0, 192, 64, 240, 0, 460, -208, 392, -232];
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ var parsed = new WavReader().Read(memory.ToArray());
+ if (parsed.FormatCode is not (1 or 3))
+ throw new NotSupportedException($"WAVE format code 0x{parsed.FormatCode:X4} is not decoded to canonical PCM.");
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(
+ parsed.SampleRate,
+ parsed.NumChannels,
+ parsed.BitsPerSample,
+ parsed.FormatCode == 3 ? AudioPcmEncoding.IeeeFloat
+ : parsed.BitsPerSample == 8 ? AudioPcmEncoding.UnsignedInteger
+ : AudioPcmEncoding.SignedInteger,
+ parsed.ChannelMask),
+ parsed.InterleavedPcm);
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"WAVE codec '{codecId}' is not supported by this writer";
+ return false;
+ }
+ if (format.Channels < 1 || format.SampleRate < 1) {
+ reason = "WAVE requires a positive sample rate and at least one channel";
+ return false;
+ }
+ var codec = codecId.ToLowerInvariant();
+ if (codec is "alaw" or "mulaw" or "ima-adpcm" or "ms-adpcm") {
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = $"{codecId} WAVE encoding requires signed PCM16 input";
+ return false;
+ }
+ if (codec is "ima-adpcm" or "ms-adpcm" && format.Channels is < 1 or > 2) {
+ reason = $"{codecId} WAVE encoding supports mono or stereo";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ if (codec == "float") {
+ reason = format.Encoding == AudioPcmEncoding.IeeeFloat && format.BitsPerSample is 32 or 64
+ ? null : "IEEE-float WAVE requires 32- or 64-bit float PCM";
+ return reason is null;
+ }
+ if (format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ reason = "floating-point input must select the 'float' WAVE codec";
+ return false;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32)) {
+ reason = "PCM WAVE supports 8/16/24/32-bit integer samples";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ switch (codecId.ToLowerInvariant()) {
+ case "pcm": {
+ var payload = (byte[])pcm.InterleavedData.Clone();
+ if (pcm.Format.BitsPerSample == 8 && pcm.Format.Encoding == AudioPcmEncoding.SignedInteger)
+ for (var i = 0; i < payload.Length; ++i) payload[i] ^= 0x80;
+ WriteWave(output, 0x0001, pcm.Format.Channels, pcm.Format.SampleRate,
+ pcm.Format.BitsPerSample, checked((ushort)pcm.Format.BytesPerFrame),
+ checked((uint)(pcm.Format.SampleRate * pcm.Format.BytesPerFrame)), payload, [], null);
+ break;
+ }
+ case "float":
+ WriteWave(output, 0x0003, pcm.Format.Channels, pcm.Format.SampleRate,
+ pcm.Format.BitsPerSample, checked((ushort)pcm.Format.BytesPerFrame),
+ checked((uint)(pcm.Format.SampleRate * pcm.Format.BytesPerFrame)), pcm.InterleavedData, [], checked((uint)pcm.FrameCount));
+ break;
+ case "alaw":
+ WriteG711(output, pcm, aLaw: true);
+ break;
+ case "mulaw":
+ WriteG711(output, pcm, aLaw: false);
+ break;
+ case "ima-adpcm":
+ WriteImaAdpcm(output, pcm, options);
+ break;
+ case "ms-adpcm":
+ WriteMsAdpcm(output, pcm, options);
+ break;
+ }
+ }
+
+ private static void WriteG711(Stream output, AudioPcmBuffer pcm, bool aLaw) {
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var encoded = aLaw ? ALawCodec.Encode(samples) : MuLawCodec.Encode(samples);
+ var blockAlign = checked((ushort)pcm.Format.Channels);
+ WriteWave(output, aLaw ? (ushort)0x0006 : (ushort)0x0007, pcm.Format.Channels, pcm.Format.SampleRate,
+ 8, blockAlign, checked((uint)(pcm.Format.SampleRate * blockAlign)), encoded, [0, 0], checked((uint)pcm.FrameCount));
+ }
+
+ private static void WriteImaAdpcm(Stream output, AudioPcmBuffer pcm, FormatCreateOptions options) {
+ var blockAlign = options.GetOptionInt("block-align", pcm.Format.Channels == 1 ? 256 : 512);
+ if (blockAlign < 4 * pcm.Format.Channels || blockAlign > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(options), "IMA ADPCM block-align is invalid.");
+ if (pcm.Format.Channels == 2 && (blockAlign - 8) % 8 != 0)
+ throw new ArgumentException("Stereo IMA ADPCM block-align must leave a data area divisible by 8 bytes.", nameof(options));
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var encoded = ImaAdpcmCodec.Encode(samples, pcm.Format.Channels, blockAlign);
+ var samplesPerBlock = (blockAlign - 4 * pcm.Format.Channels) * 2 / pcm.Format.Channels + 1;
+ Span extra = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt16LittleEndian(extra, 2);
+ BinaryPrimitives.WriteUInt16LittleEndian(extra[2..], checked((ushort)samplesPerBlock));
+ var average = checked((uint)((long)pcm.Format.SampleRate * blockAlign / samplesPerBlock));
+ WriteWave(output, 0x0011, pcm.Format.Channels, pcm.Format.SampleRate, 4,
+ checked((ushort)blockAlign), average, encoded, extra.ToArray(), checked((uint)pcm.FrameCount));
+ }
+
+ private static void WriteMsAdpcm(Stream output, AudioPcmBuffer pcm, FormatCreateOptions options) {
+ var blockAlign = options.GetOptionInt("block-align", pcm.Format.Channels == 1 ? 256 : 512);
+ var headerBytes = 7 * pcm.Format.Channels;
+ if (blockAlign < headerBytes || blockAlign > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(options), "MS ADPCM block-align is invalid.");
+ var samples = ReadPcm16(pcm.InterleavedData);
+ var encoded = MsAdpcmCodec.Encode(samples, pcm.Format.Channels, blockAlign);
+ var samplesPerBlock = 2 + (blockAlign - headerBytes) * 2 / pcm.Format.Channels;
+
+ var extra = new byte[6 + MsAdpcmCoefficients.Length * 2];
+ BinaryPrimitives.WriteUInt16LittleEndian(extra, checked((ushort)(extra.Length - 2)));
+ BinaryPrimitives.WriteUInt16LittleEndian(extra.AsSpan(2), checked((ushort)samplesPerBlock));
+ BinaryPrimitives.WriteUInt16LittleEndian(extra.AsSpan(4), 7);
+ for (var i = 0; i < MsAdpcmCoefficients.Length; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(extra.AsSpan(6 + i * 2), MsAdpcmCoefficients[i]);
+
+ var average = checked((uint)((long)pcm.Format.SampleRate * blockAlign / samplesPerBlock));
+ WriteWave(output, 0x0002, pcm.Format.Channels, pcm.Format.SampleRate, 4,
+ checked((ushort)blockAlign), average, encoded, extra, checked((uint)pcm.FrameCount));
+ }
+
+ private static void WriteWave(Stream output, ushort formatTag, int channels, int sampleRate,
+ int bitsPerSample, ushort blockAlign, uint averageBytesPerSecond, byte[] data, byte[] extraFmt, uint? factFrames) {
+ var fmtBodyLength = 16 + extraFmt.Length;
+ var fmtPadded = fmtBodyLength + (fmtBodyLength & 1);
+ var factChunkLength = factFrames.HasValue ? 12 : 0;
+ var dataPadded = data.Length + (data.Length & 1);
+ var riffPayloadLength = checked(4 + 8 + fmtPadded + factChunkLength + 8 + dataPadded);
+
+ Span riff = stackalloc byte[12];
+ "RIFF"u8.CopyTo(riff);
+ BinaryPrimitives.WriteUInt32LittleEndian(riff[4..], checked((uint)riffPayloadLength));
+ "WAVE"u8.CopyTo(riff[8..]);
+ output.Write(riff);
+
+ Span fmtHeader = stackalloc byte[8];
+ "fmt "u8.CopyTo(fmtHeader);
+ BinaryPrimitives.WriteUInt32LittleEndian(fmtHeader[4..], checked((uint)fmtBodyLength));
+ output.Write(fmtHeader);
+ Span fmt = stackalloc byte[16];
+ BinaryPrimitives.WriteUInt16LittleEndian(fmt, formatTag);
+ BinaryPrimitives.WriteUInt16LittleEndian(fmt[2..], checked((ushort)channels));
+ BinaryPrimitives.WriteUInt32LittleEndian(fmt[4..], checked((uint)sampleRate));
+ BinaryPrimitives.WriteUInt32LittleEndian(fmt[8..], averageBytesPerSecond);
+ BinaryPrimitives.WriteUInt16LittleEndian(fmt[12..], blockAlign);
+ BinaryPrimitives.WriteUInt16LittleEndian(fmt[14..], checked((ushort)bitsPerSample));
+ output.Write(fmt);
+ output.Write(extraFmt);
+ if ((fmtBodyLength & 1) != 0) output.WriteByte(0);
+
+ if (factFrames is { } frames) {
+ Span fact = stackalloc byte[12];
+ "fact"u8.CopyTo(fact);
+ BinaryPrimitives.WriteUInt32LittleEndian(fact[4..], 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(fact[8..], frames);
+ output.Write(fact);
+ }
+
+ Span dataHeader = stackalloc byte[8];
+ "data"u8.CopyTo(dataHeader);
+ BinaryPrimitives.WriteUInt32LittleEndian(dataHeader[4..], checked((uint)data.Length));
+ output.Write(dataHeader);
+ output.Write(data);
+ if ((data.Length & 1) != 0) output.WriteByte(0);
+ }
+
+ private static short[] ReadPcm16(ReadOnlySpan bytes) {
+ if ((bytes.Length & 1) != 0) throw new InvalidDataException("PCM16 payload has odd length.");
+ var samples = new short[bytes.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(bytes.Slice(i * 2, 2));
+ return samples;
+ }
+}
diff --git a/Compression.Lib/WavPackAudioPacketAdapter.cs b/Compression.Lib/WavPackAudioPacketAdapter.cs
new file mode 100644
index 000000000..7e22d2ab4
--- /dev/null
+++ b/Compression.Lib/WavPackAudioPacketAdapter.cs
@@ -0,0 +1,152 @@
+using System.Buffers.Binary;
+using Codec.WavPack;
+using Compression.Registry;
+
+namespace Compression.Lib;
+
+///
+/// Packet-preserving WavPack v4/v5 adapter based on the published 32-byte block header specification.
+///
+internal sealed class WavPackAudioPacketAdapter : IAudioDemuxSource, IAudioMuxTarget {
+ internal static readonly WavPackAudioPacketAdapter Instance = new();
+
+ private const int HeaderSize = 32;
+ private static readonly string[] MuxCodecs = ["wavpack"];
+ private static readonly int[] SampleRates = [
+ 6000, 8000, 9600, 11025, 12000, 16000, 22050, 24000,
+ 32000, 44100, 48000, 64000, 88200, 96000, 192000, 0,
+ ];
+
+ public IReadOnlyList SupportedMuxCodecs => MuxCodecs;
+
+ public bool TryDemux(Stream input, out AudioEncodedStream? stream) {
+ ArgumentNullException.ThrowIfNull(input);
+ var bytes = Materialize(input);
+ stream = null;
+ if (bytes.Length < 4 || !bytes.AsSpan(0, 4).SequenceEqual("wvpk"u8)) return false;
+
+ var packets = new List();
+ WavPackBlockHeader? firstAudio = null;
+ var offset = 0;
+ while (offset < bytes.Length) {
+ if (IsId3v1(bytes, offset)) {
+ offset += 128;
+ break;
+ }
+ if (bytes.Length - offset < HeaderSize)
+ throw new InvalidDataException($"Truncated WavPack block header at byte offset {offset}.");
+ var header = ParseBlockHeader(bytes.AsSpan(offset, HeaderSize));
+ var blockSize = checked((long)header.CkSize + 8L);
+ if (blockSize < HeaderSize)
+ throw new InvalidDataException($"Invalid WavPack block size {blockSize} at byte offset {offset}.");
+ if (blockSize > bytes.Length - offset)
+ throw new InvalidDataException($"Truncated WavPack block at byte offset {offset}: expected {blockSize} bytes.");
+ if (blockSize > int.MaxValue)
+ throw new InvalidDataException("WavPack block exceeds the supported in-memory packet size.");
+
+ var packetBytes = bytes.AsSpan(offset, (int)blockSize).ToArray();
+ var granulePosition = checked((long)(header.BlockIndex + header.BlockSamples));
+ packets.Add(new AudioPacket(packetBytes, header.BlockSamples, granulePosition));
+ if (header.BlockSamples != 0) firstAudio ??= header;
+ offset += (int)blockSize;
+ }
+
+ if (offset != bytes.Length)
+ throw new InvalidDataException($"Unexpected trailing data after WavPack blocks at byte offset {offset}.");
+ if (packets.Count == 0)
+ throw new InvalidDataException("WavPack stream contains no complete blocks.");
+
+ var fallback = firstAudio ?? ParseBlockHeader(packets[0].Data);
+ var sampleRate = SampleRates[(int)((fallback.Flags >> 23) & 0xF)];
+ var channels = (fallback.Flags & 0x4) != 0 ? 1 : 2;
+ var bitsPerSample = ((int)(fallback.Flags & 0x3) + 1) * 8;
+ var isFloat = (fallback.Flags & 0x80) != 0;
+ try {
+ using var probe = new MemoryStream(bytes, writable: false);
+ var info = WavPackCodec.ReadStreamInfo(probe);
+ sampleRate = info.SampleRate;
+ channels = info.Channels;
+ bitsPerSample = info.BitsPerSample;
+ isFloat = info.IsFloat;
+ } catch (Exception ex) when (ex is InvalidDataException or NotSupportedException or EndOfStreamException) {
+ // Packet preservation is structural and can remain valid for a legal stream variant unsupported
+ // by the local PCM decoder. The block header still supplies baseline stream information.
+ }
+
+ var properties = new Dictionary(StringComparer.OrdinalIgnoreCase) {
+ ["wavpack-version"] = $"0x{fallback.Version:X4}",
+ ["block-count"] = packets.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["sample-format"] = isFloat ? "float" : "integer",
+ };
+ stream = new AudioEncodedStream(
+ new AudioStreamFormat("wavpack", sampleRate, channels, bitsPerSample, properties),
+ packets);
+ return true;
+ }
+
+ public bool CanMux(AudioStreamFormat stream, FormatCreateOptions options, out string? reason) {
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!stream.CodecId.Equals("wavpack", StringComparison.OrdinalIgnoreCase)) {
+ reason = $"raw WavPack accepts WavPack blocks, not codec '{stream.CodecId}'";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void Mux(Stream output, AudioEncodedStream stream, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanMux(stream.Format, options, out var reason))
+ throw new NotSupportedException(reason);
+ if (stream.Packets.Count == 0)
+ throw new ArgumentException("WavPack muxing requires at least one block.", nameof(stream));
+
+ foreach (var packet in stream.Packets) {
+ if (packet.IsHeader)
+ throw new InvalidDataException("WavPack block streams do not use out-of-band header packets.");
+ if (packet.Data.Length < HeaderSize)
+ throw new InvalidDataException("WavPack packet is shorter than the 32-byte block header.");
+ var header = ParseBlockHeader(packet.Data);
+ var expectedLength = checked((long)header.CkSize + 8L);
+ if (expectedLength != packet.Data.Length)
+ throw new InvalidDataException($"WavPack packet length {packet.Data.Length} does not match block size {expectedLength}.");
+ if (packet.DurationSamples > 0 && packet.DurationSamples != header.BlockSamples)
+ throw new InvalidDataException("WavPack packet duration does not match block_samples.");
+ output.Write(packet.Data);
+ }
+ }
+
+ private static WavPackBlockHeader ParseBlockHeader(ReadOnlySpan bytes) {
+ if (bytes.Length < HeaderSize || !bytes[..4].SequenceEqual("wvpk"u8))
+ throw new InvalidDataException("WavPack packet does not begin with a valid 'wvpk' block header.");
+ var version = BinaryPrimitives.ReadUInt16LittleEndian(bytes[8..]);
+ if (version is < 0x0402 or > 0x0410)
+ throw new InvalidDataException($"Unsupported WavPack block version 0x{version:X4}.");
+ var blockIndex = ((ulong)bytes[10] << 32) | BinaryPrimitives.ReadUInt32LittleEndian(bytes[16..]);
+ return new WavPackBlockHeader(
+ BinaryPrimitives.ReadUInt32LittleEndian(bytes[4..]),
+ version,
+ blockIndex,
+ BinaryPrimitives.ReadUInt32LittleEndian(bytes[20..]),
+ BinaryPrimitives.ReadUInt32LittleEndian(bytes[24..]));
+ }
+
+ private static bool IsId3v1(ReadOnlySpan bytes, int offset)
+ => bytes.Length - offset == 128 && bytes.Slice(offset, 3).SequenceEqual("TAG"u8);
+
+ private static byte[] Materialize(Stream input) {
+ using var copy = new MemoryStream();
+ input.CopyTo(copy);
+ return copy.ToArray();
+ }
+
+ private readonly record struct WavPackBlockHeader(
+ uint CkSize,
+ ushort Version,
+ ulong BlockIndex,
+ uint BlockSamples,
+ uint Flags);
+}
diff --git a/Compression.Registry/AudioConversionContracts.cs b/Compression.Registry/AudioConversionContracts.cs
new file mode 100644
index 000000000..93a3c2388
--- /dev/null
+++ b/Compression.Registry/AudioConversionContracts.cs
@@ -0,0 +1,101 @@
+namespace Compression.Registry;
+
+/// Canonical PCM sample representation used by the cross-format audio pipeline.
+public enum AudioPcmEncoding {
+ UnsignedInteger,
+ SignedInteger,
+ IeeeFloat,
+}
+
+/// Describes interleaved PCM independently of any particular container.
+public sealed record AudioPcmFormat(
+ int SampleRate,
+ int Channels,
+ int BitsPerSample,
+ AudioPcmEncoding Encoding = AudioPcmEncoding.SignedInteger,
+ ulong? ChannelMask = null
+) {
+ public int BytesPerSample => checked((this.BitsPerSample + 7) / 8);
+ public int BytesPerFrame => checked(this.BytesPerSample * this.Channels);
+}
+
+/// Materialized interleaved PCM together with its format.
+public sealed record AudioPcmBuffer(AudioPcmFormat Format, byte[] InterleavedData) {
+ public long FrameCount => this.Format.BytesPerFrame == 0 ? 0 : this.InterleavedData.LongLength / this.Format.BytesPerFrame;
+}
+
+/// Codec-level description of an encoded audio stream.
+public sealed record AudioStreamFormat(
+ string CodecId,
+ int SampleRate,
+ int Channels,
+ int BitsPerSample = 0,
+ IReadOnlyDictionary? Properties = null
+);
+
+/// One encoded access unit/packet suitable for packet-preserving remux.
+public sealed record AudioPacket(
+ byte[] Data,
+ long DurationSamples = 0,
+ long? GranulePosition = null,
+ bool IsHeader = false
+);
+
+/// Container-neutral encoded audio stream.
+public sealed record AudioEncodedStream(
+ AudioStreamFormat Format,
+ IReadOnlyList Packets,
+ byte[]? CodecPrivateData = null
+);
+
+///
+/// Marker for descriptors that are valid participants in audio conversion even when
+/// their primary registry category is not (for example MP4/MOV).
+///
+public interface IAudioContainerFormat;
+
+/// Capability for lossless/decoded conversion through canonical PCM.
+public interface IAudioPcmSource {
+ AudioPcmBuffer DecodePcm(Stream input);
+}
+
+/// Capability for encoding canonical PCM into a target format/container.
+public interface IAudioPcmTarget {
+ IReadOnlyList SupportedEncodeCodecs { get; }
+
+ bool CanEncode(
+ AudioPcmFormat format,
+ string codecId,
+ FormatCreateOptions options,
+ out string? reason
+ );
+
+ void EncodePcm(
+ Stream output,
+ AudioPcmBuffer pcm,
+ string codecId,
+ FormatCreateOptions options
+ );
+}
+
+/// Capability for exposing encoded packets without decoding them.
+public interface IAudioDemuxSource {
+ bool TryDemux(Stream input, out AudioEncodedStream? stream);
+}
+
+/// Capability for muxing already-encoded packets without re-encoding.
+public interface IAudioMuxTarget {
+ IReadOnlyList SupportedMuxCodecs { get; }
+
+ bool CanMux(
+ AudioStreamFormat stream,
+ FormatCreateOptions options,
+ out string? reason
+ );
+
+ void Mux(
+ Stream output,
+ AudioEncodedStream stream,
+ FormatCreateOptions options
+ );
+}
diff --git a/Compression.Registry/FormatCreateOptions.cs b/Compression.Registry/FormatCreateOptions.cs
index f607e2bed..62623d71e 100644
--- a/Compression.Registry/FormatCreateOptions.cs
+++ b/Compression.Registry/FormatCreateOptions.cs
@@ -4,11 +4,28 @@ namespace Compression.Registry;
/// Options for archive/stream creation, passed from the orchestration layer to format descriptors.
///
public sealed class FormatCreateOptions {
+ private string? _methodName;
+
+ /// Creates options with an optional compression/codec method.
+ public FormatCreateOptions(string? Method = null) => this._methodName = Method;
+
/// Encryption password.
public string? Password { get; init; }
- /// Compression method name (e.g. "deflate", "lzma").
- public string? MethodName { get; init; }
+ /// Compression method name (e.g. "deflate", "lzma", "aac", "opus").
+ public string? MethodName {
+ get => this._methodName;
+ init => this._methodName = value;
+ }
+
+ ///
+ /// Alias for used by codec/container creation paths.
+ /// Both properties share the same backing value.
+ ///
+ public string? Method {
+ get => this._methodName;
+ init => this._methodName = value;
+ }
/// Whether "+" optimization was requested.
public bool Optimize { get; init; }
@@ -41,46 +58,39 @@ public sealed class FormatCreateOptions {
public HashSet? IncompressiblePaths { get; init; }
///
- /// Format-specific tunable knobs collected from a
- /// . Keys come from
- /// ; values are in canonical string
- /// form (the format's writer parses them per its schema). Writers should
- /// call or
- /// rather than reading the dict
- /// directly, so a missing entry falls back to the schema default.
+ /// Format-specific tunable knobs collected from an .
+ /// The collection is initialized so callers can use collection/index initializers
+ /// without allocating a dictionary explicitly.
///
- public IReadOnlyDictionary? FormatSpecific { get; init; }
+ public IDictionary FormatSpecific { get; init; }
+ = new Dictionary(StringComparer.OrdinalIgnoreCase);
- ///
- /// True when the caller explicitly supplied (with a
- /// non-empty value). Writers use this to distinguish "caller pinned a size"
- /// from "use the format default / auto-optimise" — an unset size must leave the
- /// auto-selection path free, while a pinned size must be honoured byte-for-byte.
- ///
+ /// True when the caller explicitly supplied a non-empty value for .
public bool HasOption(string key)
- => this.FormatSpecific != null
- && this.FormatSpecific.TryGetValue(key, out var v)
- && !string.IsNullOrEmpty(v);
+ => this.FormatSpecific.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value);
/// Reads a format-specific string option, returning if absent.
- public string GetOption(string key, string fallback) {
- if (this.FormatSpecific == null) return fallback;
- return this.FormatSpecific.TryGetValue(key, out var v) ? v : fallback;
- }
+ public string GetOption(string key, string fallback)
+ => this.FormatSpecific.TryGetValue(key, out var value) ? value : fallback;
+
+ /// Reads a string option, returning if absent.
+ public string? GetString(string key)
+ => this.FormatSpecific.TryGetValue(key, out var value) ? value : null;
/// Reads a format-specific integer option. Returns if absent or unparsable.
- public int GetOptionInt(string key, int fallback) {
- if (this.FormatSpecific == null) return fallback;
- if (!this.FormatSpecific.TryGetValue(key, out var v)) return fallback;
- return int.TryParse(v, System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : fallback;
- }
+ public int GetOptionInt(string key, int fallback)
+ => this.TryGetInt(key, out var value) ? value : fallback;
+
+ /// Attempts to read a format-specific invariant-culture integer.
+ public bool TryGetInt(string key, out int value)
+ => this.FormatSpecific.TryGetValue(key, out var text)
+ && int.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out value);
- /// Reads a format-specific boolean option. Accepts "true"/"false"/"1"/"0" (case-insensitive).
+ /// Reads a format-specific boolean option. Accepts true/false/1/0 (case-insensitive).
public bool GetOptionBool(string key, bool fallback) {
- if (this.FormatSpecific == null) return fallback;
- if (!this.FormatSpecific.TryGetValue(key, out var v)) return fallback;
- return v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1" ? true
- : v.Equals("false", StringComparison.OrdinalIgnoreCase) || v == "0" ? false
+ if (!this.FormatSpecific.TryGetValue(key, out var value)) return fallback;
+ return value.Equals("true", StringComparison.OrdinalIgnoreCase) || value == "1" ? true
+ : value.Equals("false", StringComparison.OrdinalIgnoreCase) || value == "0" ? false
: fallback;
}
}
diff --git a/Compression.Tests/Audio/AmrAndCafConversionTests.cs b/Compression.Tests/Audio/AmrAndCafConversionTests.cs
new file mode 100644
index 000000000..869249d0b
--- /dev/null
+++ b/Compression.Tests/Audio/AmrAndCafConversionTests.cs
@@ -0,0 +1,121 @@
+using System.Buffers.Binary;
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.AmrNb;
+using FileFormat.AmrWb;
+using FileFormat.Caf;
+using FileFormat.Wav;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class AmrAndCafConversionTests {
+
+ [Test]
+ public void AmrNb_Descriptor_EncodesDemuxesAndDecodesStorageFrames() {
+ const int frames = 173;
+ var pcm = BuildPcm16(8_000, 1, frames);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, 8_000, 16);
+ var descriptor = new AmrNbFormatDescriptor();
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var amr = new MemoryStream();
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), amr, descriptor,
+ new FormatCreateOptions(Method: "amr-nb"));
+
+ var bytes = amr.ToArray();
+ Assert.That(bytes.AsSpan(0, 6).SequenceEqual("#!AMR\n"u8), Is.True);
+
+ amr.Position = 0;
+ Assert.That(descriptor.TryDemux(amr, out var encoded), Is.True);
+ Assert.That(encoded, Is.Not.Null);
+ Assert.Multiple(() => {
+ Assert.That(encoded!.Format.CodecId, Is.EqualTo("amr-nb"));
+ Assert.That(encoded.Packets.Count, Is.EqualTo(2));
+ Assert.That(encoded.Packets.All(static packet => packet.DurationSamples == 160), Is.True);
+ });
+
+ amr.Position = 0;
+ var decoded = descriptor.DecodePcm(amr);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(8_000));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(1));
+ Assert.That(decoded.InterleavedData.Length, Is.EqualTo(2 * 160 * 2));
+ });
+
+ using var truncated = new MemoryStream(bytes[..^1], writable: false);
+ Assert.That(descriptor.TryDemux(truncated, out _), Is.False);
+ }
+
+ [Test]
+ public void AmrWb_Descriptor_EncodesDemuxesAndDecodesStorageFrames() {
+ const int frames = 333;
+ var pcm = BuildPcm16(16_000, 1, frames);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, 16_000, 16);
+ var descriptor = new AmrWbFormatDescriptor();
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var amr = new MemoryStream();
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), amr, descriptor,
+ new FormatCreateOptions(Method: "amr-wb"));
+
+ var bytes = amr.ToArray();
+ Assert.That(bytes.AsSpan(0, 9).SequenceEqual("#!AMR-WB\n"u8), Is.True);
+
+ amr.Position = 0;
+ Assert.That(descriptor.TryDemux(amr, out var encoded), Is.True);
+ Assert.That(encoded, Is.Not.Null);
+ Assert.Multiple(() => {
+ Assert.That(encoded!.Format.CodecId, Is.EqualTo("amr-wb"));
+ Assert.That(encoded.Packets.Count, Is.EqualTo(2));
+ Assert.That(encoded.Packets.All(static packet => packet.DurationSamples == 320), Is.True);
+ });
+
+ amr.Position = 0;
+ var decoded = descriptor.DecodePcm(amr);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(16_000));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(1));
+ Assert.That(decoded.InterleavedData.Length, Is.EqualTo(2 * 320 * 2));
+ });
+
+ using var truncated = new MemoryStream(bytes[..^1], writable: false);
+ Assert.That(descriptor.TryDemux(truncated, out _), Is.False);
+ }
+
+ [Test]
+ public void CafIma4_PaktPreservesOriginalValidFrameCount() {
+ const int sampleRate = 44_100;
+ const int channels = 2;
+ const int frames = 130;
+ var pcm = BuildPcm16(sampleRate, channels, frames);
+ var wav = PcmCodec.ToWavBlob(pcm, channels, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var caf = new MemoryStream();
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), caf, new CafFormatDescriptor(),
+ new FormatCreateOptions(Method: "ima4"));
+
+ var parsed = new CafReader().Read(caf.ToArray());
+ Assert.Multiple(() => {
+ Assert.That(parsed.FormatId, Is.EqualTo("lpcm"));
+ Assert.That(parsed.ValidFrames, Is.EqualTo(frames));
+ Assert.That(parsed.BitsPerSample, Is.EqualTo(16));
+ Assert.That(parsed.NumChannels, Is.EqualTo(channels));
+ Assert.That(parsed.InterleavedPcm.Length, Is.EqualTo(frames * channels * 2));
+ Assert.That(parsed.InterleavedPcm.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ var sample = (short)Math.Round(Math.Sin(2 * Math.PI * (310 + channel * 170) * frame / sampleRate) * 12_000);
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan((frame * channels + channel) * 2, 2), sample);
+ }
+ return pcm;
+ }
+}
diff --git a/Compression.Tests/Audio/AudioContainerAdapterTests.cs b/Compression.Tests/Audio/AudioContainerAdapterTests.cs
new file mode 100644
index 000000000..1a0ec493f
--- /dev/null
+++ b/Compression.Tests/Audio/AudioContainerAdapterTests.cs
@@ -0,0 +1,216 @@
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Aiff;
+using FileFormat.Au;
+using FileFormat.Flac;
+using FileFormat.Wav;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class AudioContainerAdapterTests {
+
+ [Test]
+ public void WavToAuPcmToFlac_IsLossless() {
+ const int sampleRate = 48_000;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 2_048);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var au = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ au,
+ new AuFormatDescriptor(),
+ new FormatCreateOptions(Method: "pcm"));
+
+ Assert.That(au.ToArray().AsSpan(0, 4).ToArray(), Is.EqualTo(".snd"u8.ToArray()));
+
+ au.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ au,
+ new AuFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(decoded.InterleavedData, Is.EqualTo(pcm));
+ });
+ }
+
+ [TestCase("mulaw", 1u)]
+ [TestCase("alaw", 27u)]
+ public void WavToAuG711ToFlac_PreservesGeometryAndSignal(string codec, uint expectedEncoding) {
+ const int sampleRate = 8_000;
+ var pcm = BuildPcm16(sampleRate, channels: 1, frames: 1_600);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var au = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ au,
+ new AuFormatDescriptor(),
+ new FormatCreateOptions(Method: codec));
+
+ var parsed = new AuReader().Read(au.ToArray());
+ Assert.That(parsed.Encoding, Is.EqualTo(expectedEncoding));
+
+ au.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ au,
+ new AuFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(1));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.EqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ [Test]
+ public void WavToAiffPcmToFlac_IsLossless() {
+ const int sampleRate = 44_100;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 2_048);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var aiff = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ aiff,
+ new AiffFormatDescriptor(),
+ new FormatCreateOptions(Method: "pcm"));
+
+ var parsed = new AiffReader().Read(aiff.ToArray());
+ Assert.Multiple(() => {
+ Assert.That(parsed.IsAifc, Is.False);
+ Assert.That(parsed.CompressionId, Is.EqualTo("NONE"));
+ Assert.That(parsed.SampleFrames, Is.EqualTo(2_048));
+ });
+
+ aiff.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ aiff,
+ new AiffFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.That(decoded.InterleavedData, Is.EqualTo(pcm));
+ }
+
+ [Test]
+ public void WavToAifcSowtToFlac_IsLossless() {
+ const int sampleRate = 48_000;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 1_536);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var aifc = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ aifc,
+ new AiffFormatDescriptor(),
+ new FormatCreateOptions(Method: "sowt"));
+
+ var parsed = new AiffReader().Read(aifc.ToArray());
+ Assert.Multiple(() => {
+ Assert.That(parsed.IsAifc, Is.True);
+ Assert.That(parsed.CompressionId, Is.EqualTo("sowt"));
+ Assert.That(parsed.SampleFrames, Is.EqualTo(1_536));
+ });
+
+ aifc.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ aifc,
+ new AiffFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.That(decoded.InterleavedData, Is.EqualTo(pcm));
+ }
+
+ [TestCase("mulaw", "ulaw")]
+ [TestCase("alaw", "alaw")]
+ [TestCase("ima4", "ima4")]
+ public void WavToAifcLossyCodecToFlac_PreservesGeometryAndSignal(string codec, string expectedCompressionId) {
+ const int sampleRate = 8_000;
+ var pcm = BuildPcm16(sampleRate, channels: 1, frames: 1_600);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var aifc = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ aifc,
+ new AiffFormatDescriptor(),
+ new FormatCreateOptions(Method: codec));
+
+ var parsed = new AiffReader().Read(aifc.ToArray());
+ Assert.Multiple(() => {
+ Assert.That(parsed.IsAifc, Is.True);
+ Assert.That(parsed.CompressionId, Is.EqualTo(expectedCompressionId));
+ Assert.That(parsed.SampleFrames, Is.EqualTo(1_600));
+ });
+
+ aifc.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ aifc,
+ new AiffFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(1));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.EqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ var frequency = 220.0 + 110.0 * channel;
+ var value = (short)Math.Round(Math.Sin(2.0 * Math.PI * frequency * frame / sampleRate) * 10_000.0);
+ System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(
+ pcm.AsSpan((frame * channels + channel) * 2, 2), value);
+ }
+ return pcm;
+ }
+}
diff --git a/Compression.Tests/Audio/AudioConversionInventoryTests.cs b/Compression.Tests/Audio/AudioConversionInventoryTests.cs
new file mode 100644
index 000000000..ed9aed13b
--- /dev/null
+++ b/Compression.Tests/Audio/AudioConversionInventoryTests.cs
@@ -0,0 +1,31 @@
+using Compression.Lib;
+using FileFormat.Aiff;
+using FileFormat.Au;
+using FileFormat.Mp3;
+using FileFormat.Wav;
+using FileFormat.WavPack;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class AudioConversionInventoryTests {
+
+ [TestCase(typeof(WavFormatDescriptor), "pcm", "ima-adpcm", "ms-adpcm")]
+ [TestCase(typeof(AiffFormatDescriptor), "pcm", "ima4", "alaw")]
+ [TestCase(typeof(AuFormatDescriptor), "pcm", "mulaw", "alaw")]
+ [TestCase(typeof(Mp3FormatDescriptor), "mp3", null, null)]
+ [TestCase(typeof(WavPackFormatDescriptor), "wavpack", null, null)]
+ public void AdaptedFormatsAdvertiseRealEncodeCapabilities(Type descriptorType, string codec1, string? codec2, string? codec3) {
+ var descriptor = (Compression.Registry.IFormatDescriptor)Activator.CreateInstance(descriptorType)!;
+ var capability = AudioConversionInventory.Describe(descriptor);
+
+ Assert.Multiple(() => {
+ Assert.That(capability.CanDecodePcm, Is.True);
+ Assert.That(capability.CanEncodePcm, Is.True);
+ Assert.That(capability.EncodeCodecs, Does.Contain(codec1));
+ if (codec2 is not null) Assert.That(capability.EncodeCodecs, Does.Contain(codec2));
+ if (codec3 is not null) Assert.That(capability.EncodeCodecs, Does.Contain(codec3));
+ });
+ }
+}
diff --git a/Compression.Tests/Audio/AudioConversionPipelineTests.cs b/Compression.Tests/Audio/AudioConversionPipelineTests.cs
new file mode 100644
index 000000000..b4d6560f2
--- /dev/null
+++ b/Compression.Tests/Audio/AudioConversionPipelineTests.cs
@@ -0,0 +1,237 @@
+using Codec.Aac;
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Aac;
+using FileFormat.Flac;
+using FileFormat.Mp3;
+using FileFormat.Mp4;
+using FileFormat.Ogg;
+using FileFormat.Wav;
+using FileFormat.WavPack;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class AudioConversionPipelineTests {
+
+ [Test]
+ public void SameFormat_IsByteExactPassthrough() {
+ var inputBytes = BuildAac(sampleRate: 44_100, channels: 1, frames: 2_048);
+ using var input = new MemoryStream(inputBytes, writable: false);
+ using var output = new MemoryStream();
+
+ var descriptor = new AacFormatDescriptor();
+ AudioConversionOperation.Convert(input, descriptor, output, descriptor);
+
+ Assert.That(output.ToArray(), Is.EqualTo(inputBytes));
+ }
+
+ [Test]
+ public void AacToM4a_RemuxPreservesEveryAccessUnit() {
+ var adts = BuildAac(sampleRate: 44_100, channels: 2, frames: 3_000);
+ var aac = new AacFormatDescriptor();
+ using var packetSource = new MemoryStream(adts, writable: false);
+ Assert.That(aac.TryDemux(packetSource, out var elementary), Is.True);
+ Assert.That(elementary, Is.Not.Null);
+
+ using var input = new MemoryStream(adts, writable: false);
+ using var output = new MemoryStream();
+ AudioConversionOperation.Convert(input, aac, output, new Mp4FormatDescriptor());
+
+ var tracks = new Mp4Demuxer().Demux(output.ToArray());
+ var audio = tracks.Single(track => track.HandlerType == "soun");
+ Assert.That(audio.CodecFourCc, Is.EqualTo("mp4a"));
+ Assert.That(audio.Samples.Count, Is.EqualTo(elementary!.Packets.Count));
+ for (var i = 0; i < audio.Samples.Count; ++i)
+ Assert.That(audio.Samples[i].Data, Is.EqualTo(elementary.Packets[i].Data), $"AAC access unit {i}");
+ }
+
+ [Test]
+ public void WavToFlac_IsLosslessThroughCommonPipeline() {
+ var pcm = BuildPcm16(44_100, channels: 2, frames: 4_096);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, 44_100, 16);
+ using var input = new MemoryStream(wav, writable: false);
+ using var encoded = new MemoryStream();
+
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ encoded,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac") {
+ FormatSpecific = { ["block-size"] = "1024", ["stereo-mode"] = "mid-side" },
+ });
+
+ encoded.Position = 0;
+ var flac = new FlacFormatDescriptor().DecodePcm(encoded);
+ Assert.Multiple(() => {
+ Assert.That(flac.Format.SampleRate, Is.EqualTo(44_100));
+ Assert.That(flac.Format.Channels, Is.EqualTo(2));
+ Assert.That(flac.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(flac.InterleavedData, Is.EqualTo(pcm));
+ });
+ }
+
+ [Test]
+ public void WavToWavPackToFlac_IsLosslessThroughAdapters() {
+ const int sampleRate = 44_100;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 3_072);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var wavInput = new MemoryStream(wav, writable: false);
+ using var wavPack = new MemoryStream();
+ AudioConversionOperation.Convert(
+ wavInput,
+ new WavFormatDescriptor(),
+ wavPack,
+ new WavPackFormatDescriptor(),
+ new FormatCreateOptions(Method: "wavpack"));
+
+ Assert.That(wavPack.ToArray().AsSpan(0, 4).ToArray(), Is.EqualTo("wvpk"u8.ToArray()));
+
+ wavPack.Position = 0;
+ using var flacOutput = new MemoryStream();
+ AudioConversionOperation.Convert(
+ wavPack,
+ new WavPackFormatDescriptor(),
+ flacOutput,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flacOutput.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flacOutput);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData, Is.EqualTo(pcm));
+ });
+ }
+
+ [Test]
+ public void WavToMp3ToFlac_PreservesGeometryAndSignal() {
+ const int sampleRate = 44_100;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 8_192);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var wavInput = new MemoryStream(wav, writable: false);
+ using var mp3 = new MemoryStream();
+ AudioConversionOperation.Convert(
+ wavInput,
+ new WavFormatDescriptor(),
+ mp3,
+ new Mp3FormatDescriptor(),
+ new FormatCreateOptions(Method: "mp3") {
+ FormatSpecific = { ["bitrate"] = "160", ["quality"] = "4", ["channel-mode"] = "joint-stereo" },
+ });
+
+ Assert.That(mp3.Length, Is.GreaterThan(0));
+
+ mp3.Position = 0;
+ using var flacOutput = new MemoryStream();
+ AudioConversionOperation.Convert(
+ mp3,
+ new Mp3FormatDescriptor(),
+ flacOutput,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flacOutput.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flacOutput);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.GreaterThan(0));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ [TestCase("vorbis")]
+ [TestCase("opus")]
+ public void WavToOgg_UsesSelectedManagedEncoder(string codec) {
+ const int sampleRate = 48_000;
+ var pcm = BuildPcm16(sampleRate, channels: 2, frames: 2_400);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+ using var input = new MemoryStream(wav, writable: false);
+ using var encoded = new MemoryStream();
+
+ var options = new FormatCreateOptions(Method: codec);
+ if (codec == "vorbis") options.FormatSpecific["quality"] = "0.35";
+ else options.FormatSpecific["bitrate"] = "96000";
+
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), encoded, new OggFormatDescriptor(), options);
+
+ var bytes = encoded.ToArray();
+ Assert.That(bytes.AsSpan(0, 4).ToArray(), Is.EqualTo("OggS"u8.ToArray()));
+ using var ogg = new MemoryStream(bytes, writable: false);
+ var decoded = new OggFormatDescriptor().DecodePcm(ogg);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.InterleavedData.Length, Is.GreaterThan(0));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ [Test]
+ public void WavToM4a_EncodesAndDecodesThroughExistingMp4AudioSurface() {
+ const int sampleRate = 44_100;
+ var pcm = BuildPcm16(sampleRate, channels: 1, frames: 2_048);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, sampleRate, 16);
+ using var input = new MemoryStream(wav, writable: false);
+ using var output = new MemoryStream();
+
+ var options = new FormatCreateOptions(Method: "aac");
+ options.FormatSpecific["bitrate"] = "64000";
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), output, new Mp4FormatDescriptor(), options);
+
+ var bytes = output.ToArray();
+ var boxes = new BoxParser().Parse(bytes);
+ Assert.That(boxes.Select(static box => box.Type), Is.EqualTo(new[] { "ftyp", "mdat", "moov" }));
+ var tracks = new Mp4Demuxer().Demux(bytes);
+ Assert.That(tracks.Count(static track => track.HandlerType == "soun"), Is.EqualTo(1));
+ Assert.That(tracks.Single(static track => track.HandlerType == "soun").Samples.Count, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public void UnsupportedTargetCodec_FailsExplicitly() {
+ var pcm = BuildPcm16(44_100, channels: 1, frames: 1_024);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, 44_100, 16);
+ using var input = new MemoryStream(wav, writable: false);
+ using var output = new MemoryStream();
+
+ var exception = Assert.Throws(() =>
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ output,
+ new OggFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac")));
+
+ Assert.That(exception!.Message, Does.Contain("flac"));
+ }
+
+ private static byte[] BuildAac(int sampleRate, int channels, int frames) {
+ var pcmBytes = BuildPcm16(sampleRate, channels, frames);
+ var samples = new short[pcmBytes.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(pcmBytes.AsSpan(i * 2, 2));
+ return AacEncoder.Encode(samples, new AacEncoderOptions(sampleRate, channels, channels == 1 ? 64_000 : 128_000));
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame) {
+ for (var channel = 0; channel < channels; ++channel) {
+ var frequency = 330.0 + channel * 220.0;
+ var value = (short)Math.Round(Math.Sin(2.0 * Math.PI * frequency * frame / sampleRate) * 12_000.0);
+ System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(
+ pcm.AsSpan((frame * channels + channel) * 2, 2), value);
+ }
+ }
+ return pcm;
+ }
+}
diff --git a/Compression.Tests/Audio/AudioPacketAdapterTests.cs b/Compression.Tests/Audio/AudioPacketAdapterTests.cs
new file mode 100644
index 000000000..40c7ae0e4
--- /dev/null
+++ b/Compression.Tests/Audio/AudioPacketAdapterTests.cs
@@ -0,0 +1,227 @@
+using System.Buffers.Binary;
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Mp3;
+using FileFormat.Wav;
+using FileFormat.WavPack;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class AudioPacketAdapterTests {
+
+ [Test]
+ public void Mp3ResolverRoute_PreservesFramesAndExcludesId3Metadata() {
+ var first = BuildMpeg1Layer3Frame(payloadSeed: 0x11);
+ var second = BuildMpeg1Layer3Frame(payloadSeed: 0x37);
+ byte[] inputBytes = [.. BuildEmptyId3v2Tag(), .. first, .. second];
+ byte[] expected = [.. first, .. second];
+
+ using var input = new MemoryStream(inputBytes, writable: false);
+ using var output = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new Mp3FormatDescriptor(),
+ output,
+ new Mp3FormatDescriptor(),
+ new FormatCreateOptions(Method: "mp3"));
+
+ Assert.That(output.ToArray(), Is.EqualTo(expected));
+
+ using var packetInput = new MemoryStream(inputBytes, writable: false);
+ Assert.That(Mp3AudioPacketAdapter.Instance.TryDemux(packetInput, out var encoded), Is.True);
+ Assert.That(encoded, Is.Not.Null);
+ Assert.Multiple(() => {
+ Assert.That(encoded!.Format.CodecId, Is.EqualTo("mp3"));
+ Assert.That(encoded.Format.SampleRate, Is.EqualTo(44_100));
+ Assert.That(encoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(encoded.Packets.Count, Is.EqualTo(2));
+ Assert.That(encoded.Packets.All(static packet => packet.DurationSamples == 1_152), Is.True);
+ Assert.That(encoded.Packets[0].Data, Is.EqualTo(first));
+ Assert.That(encoded.Packets[1].Data, Is.EqualTo(second));
+ });
+ }
+
+ [Test]
+ public void Mp3Demux_RejectsTruncatedFrame() {
+ var frame = BuildMpeg1Layer3Frame();
+ using var input = new MemoryStream(frame[..^1], writable: false);
+
+ var exception = Assert.Throws(() =>
+ Mp3AudioPacketAdapter.Instance.TryDemux(input, out _));
+
+ Assert.That(exception!.Message, Does.Contain("Truncated MPEG audio frame"));
+ }
+
+ [Test]
+ public void Mp3Demux_RejectsGeometryTransition() {
+ var first = BuildMpeg1Layer3Frame(sampleRateIndex: 0);
+ var second = BuildMpeg1Layer3Frame(sampleRateIndex: 1);
+ byte[] bytes = [.. first, .. second];
+ using var input = new MemoryStream(bytes, writable: false);
+
+ var exception = Assert.Throws(() =>
+ Mp3AudioPacketAdapter.Instance.TryDemux(input, out _));
+
+ Assert.That(exception!.Message, Does.Contain("changes version, layer, sample rate, or channel count"));
+ }
+
+ [Test]
+ public void Mp3Demux_RejectsFreeFormatFrames() {
+ var freeFormatHeader = new byte[] { 0xFF, 0xFB, 0x00, 0x00 };
+ using var input = new MemoryStream(freeFormatHeader, writable: false);
+
+ Assert.Throws(() =>
+ Mp3AudioPacketAdapter.Instance.TryDemux(input, out _));
+ }
+
+ [Test]
+ public void WavPackResolverRoute_PreservesPhysicalBlocks() {
+ var first = BuildWavPackBlock(blockIndex: 0, blockSamples: 100, totalSamples: 220);
+ var second = BuildWavPackBlock(blockIndex: 100, blockSamples: 120, totalSamples: 220);
+ byte[] bytes = [.. first, .. second];
+
+ using var input = new MemoryStream(bytes, writable: false);
+ using var output = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavPackFormatDescriptor(),
+ output,
+ new WavPackFormatDescriptor(),
+ new FormatCreateOptions(Method: "wavpack"));
+
+ Assert.That(output.ToArray(), Is.EqualTo(bytes));
+
+ using var packetInput = new MemoryStream(bytes, writable: false);
+ Assert.That(WavPackAudioPacketAdapter.Instance.TryDemux(packetInput, out var encoded), Is.True);
+ Assert.That(encoded, Is.Not.Null);
+ Assert.Multiple(() => {
+ Assert.That(encoded!.Format.CodecId, Is.EqualTo("wavpack"));
+ Assert.That(encoded.Format.SampleRate, Is.EqualTo(44_100));
+ Assert.That(encoded.Format.Channels, Is.EqualTo(2));
+ Assert.That(encoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(encoded.Packets.Count, Is.EqualTo(2));
+ Assert.That(encoded.Packets[0].DurationSamples, Is.EqualTo(100));
+ Assert.That(encoded.Packets[0].GranulePosition, Is.EqualTo(100));
+ Assert.That(encoded.Packets[1].DurationSamples, Is.EqualTo(120));
+ Assert.That(encoded.Packets[1].GranulePosition, Is.EqualTo(220));
+ });
+ }
+
+ [Test]
+ public void WavPackDemux_PreservesVersion5FortyBitBlockIndex() {
+ const ulong blockIndex = (1UL << 32) + 7;
+ var block = BuildWavPackBlock(blockIndex, blockSamples: 3, totalSamples: 0xFFFF_FFFF);
+ using var input = new MemoryStream(block, writable: false);
+
+ Assert.That(WavPackAudioPacketAdapter.Instance.TryDemux(input, out var encoded), Is.True);
+ Assert.That(encoded, Is.Not.Null);
+ Assert.That(encoded!.Packets.Single().GranulePosition, Is.EqualTo((long)blockIndex + 3));
+ }
+
+ [Test]
+ public void WavPackDemux_RejectsTruncatedBlock() {
+ var block = BuildWavPackBlock(blockIndex: 0, blockSamples: 100, totalSamples: 100);
+ using var input = new MemoryStream(block[..^1], writable: false);
+
+ var exception = Assert.Throws(() =>
+ WavPackAudioPacketAdapter.Instance.TryDemux(input, out _));
+
+ Assert.That(exception!.Message, Does.Contain("Truncated WavPack block"));
+ }
+
+ [Test]
+ public void WavPackDemux_RejectsUnsupportedBlockVersion() {
+ var block = BuildWavPackBlock(blockIndex: 0, blockSamples: 100, totalSamples: 100);
+ BinaryPrimitives.WriteUInt16LittleEndian(block.AsSpan(8), 0x0401);
+ using var input = new MemoryStream(block, writable: false);
+
+ Assert.Throws(() =>
+ WavPackAudioPacketAdapter.Instance.TryDemux(input, out _));
+ }
+
+ [TestCase(typeof(Mp3FormatDescriptor), "mp3")]
+ [TestCase(typeof(WavPackFormatDescriptor), "wavpack")]
+ public void ResolverBackedPacketCapabilities_AreReported(Type descriptorType, string codec) {
+ var descriptor = (IFormatDescriptor)Activator.CreateInstance(descriptorType)!;
+ var capability = AudioConversionInventory.Describe(descriptor);
+
+ Assert.Multiple(() => {
+ Assert.That(capability.CanDemuxEncoded, Is.True);
+ Assert.That(capability.CanMuxEncoded, Is.True);
+ Assert.That(capability.MuxCodecs, Does.Contain(codec));
+ });
+ }
+
+ [Test]
+ public void GenericArchiveCreateCapability_IsNotAnAudioSink() {
+ var target = new GenericArchiveTarget();
+ var capability = AudioConversionInventory.Describe(target);
+ Assert.Multiple(() => {
+ Assert.That(capability.CanCreatePseudoArchive, Is.False);
+ Assert.That(capability.CanBeTarget, Is.False);
+ });
+
+ var wav = PcmCodec.ToWavBlob(new byte[128], numChannels: 1, sampleRate: 8_000, bitsPerSample: 16);
+ using var input = new MemoryStream(wav, writable: false);
+ using var output = new MemoryStream();
+
+ Assert.Throws(() =>
+ AudioConversionOperation.Convert(input, new WavFormatDescriptor(), output, target));
+ Assert.That(target.CreateCalled, Is.False);
+ }
+
+ private static byte[] BuildMpeg1Layer3Frame(int sampleRateIndex = 0, byte payloadSeed = 0x5A) {
+ int[] bitrates = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320];
+ int[] sampleRates = [44_100, 48_000, 32_000];
+ const int bitrateIndex = 9;
+ var frameSize = 144 * bitrates[bitrateIndex] * 1000 / sampleRates[sampleRateIndex];
+ var frame = new byte[frameSize];
+ var header = 0xFFE0_0000u |
+ 3u << 19 |
+ 1u << 17 |
+ 1u << 16 |
+ (uint)bitrateIndex << 12 |
+ (uint)sampleRateIndex << 10;
+ BinaryPrimitives.WriteUInt32BigEndian(frame, header);
+ for (var i = 4; i < frame.Length; ++i)
+ frame[i] = (byte)(payloadSeed + i * 17);
+ return frame;
+ }
+
+ private static byte[] BuildEmptyId3v2Tag()
+ => [0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
+
+ private static byte[] BuildWavPackBlock(ulong blockIndex, uint blockSamples, uint totalSamples) {
+ const uint flags = 1u | (9u << 23) | 0x800u | 0x1000u;
+ var block = new byte[32];
+ "wvpk"u8.CopyTo(block);
+ BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(4), 24);
+ BinaryPrimitives.WriteUInt16LittleEndian(block.AsSpan(8), 0x0410);
+ block[10] = checked((byte)(blockIndex >> 32));
+ BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(12), totalSamples);
+ BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(16), (uint)blockIndex);
+ BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(20), blockSamples);
+ BinaryPrimitives.WriteUInt32LittleEndian(block.AsSpan(24), flags);
+ return block;
+ }
+
+ private sealed class GenericArchiveTarget : IFormatDescriptor, IArchiveCreatable {
+ public bool CreateCalled { get; private set; }
+ public string Id => "GenericArchiveTarget";
+ public string DisplayName => "Generic archive target";
+ public FormatCategory Category => FormatCategory.Archive;
+ public FormatCapabilities Capabilities => FormatCapabilities.CanCreate;
+ public string DefaultExtension => ".fake";
+ public IReadOnlyList Extensions => [this.DefaultExtension];
+ public IReadOnlyList CompoundExtensions => [];
+ public IReadOnlyList MagicSignatures => [];
+ public IReadOnlyList Methods => [new("stored", "Stored")];
+ public string? TarCompressionFormatId => null;
+
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options)
+ => this.CreateCalled = true;
+ }
+}
diff --git a/Compression.Tests/Audio/CafConversionTests.cs b/Compression.Tests/Audio/CafConversionTests.cs
new file mode 100644
index 000000000..0cbf2ea0e
--- /dev/null
+++ b/Compression.Tests/Audio/CafConversionTests.cs
@@ -0,0 +1,98 @@
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Caf;
+using FileFormat.Flac;
+using FileFormat.Wav;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class CafConversionTests {
+
+ [Test]
+ public void WavToCafLpcmToFlac_IsLosslessAndUsesStandardFlags() {
+ const int sampleRate = 48_000;
+ var pcm = BuildPcm16(sampleRate, 2, 2_048);
+ var wav = PcmCodec.ToWavBlob(pcm, 2, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var caf = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ caf,
+ new CafFormatDescriptor(),
+ new FormatCreateOptions(Method: "lpcm"));
+
+ var parsed = new CafReader().Read(caf.ToArray());
+ Assert.Multiple(() => {
+ Assert.That(parsed.FormatId, Is.EqualTo("lpcm"));
+ Assert.That(parsed.FormatFlags & 0x2u, Is.Zero, "big-endian flag must be clear for canonical LE PCM");
+ Assert.That(parsed.FormatFlags & 0x4u, Is.Not.Zero, "signed-integer flag");
+ Assert.That(parsed.FormatFlags & 0x8u, Is.Not.Zero, "packed flag");
+ Assert.That(parsed.InterleavedPcm, Is.EqualTo(pcm));
+ });
+
+ caf.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ caf,
+ new CafFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.That(decoded.InterleavedData, Is.EqualTo(pcm));
+ }
+
+ [TestCase("mulaw")]
+ [TestCase("alaw")]
+ public void WavToCafG711ToFlac_PreservesGeometryAndSignal(string codec) {
+ const int sampleRate = 8_000;
+ var pcm = BuildPcm16(sampleRate, 1, 1_600);
+ var wav = PcmCodec.ToWavBlob(pcm, 1, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var caf = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ caf,
+ new CafFormatDescriptor(),
+ new FormatCreateOptions(Method: codec));
+
+ caf.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ caf,
+ new CafFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(1));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.EqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ var value = (short)Math.Round(Math.Sin(2.0 * Math.PI * (280.0 + channel * 150.0) * frame / sampleRate) * 11_000.0);
+ System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(
+ pcm.AsSpan((frame * channels + channel) * 2, 2), value);
+ }
+ return pcm;
+ }
+}
diff --git a/Compression.Tests/Audio/ElementaryAudioConversionTests.cs b/Compression.Tests/Audio/ElementaryAudioConversionTests.cs
new file mode 100644
index 000000000..1c514ebe2
--- /dev/null
+++ b/Compression.Tests/Audio/ElementaryAudioConversionTests.cs
@@ -0,0 +1,107 @@
+using System.Buffers.Binary;
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Ac3;
+using FileFormat.Dts;
+using FileFormat.Flac;
+using FileFormat.Wav;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class ElementaryAudioConversionTests {
+
+ [Test]
+ public void WavToAc3ToFlac_PreservesGeometryAndSignal() {
+ const int sampleRate = 48_000;
+ const int channels = 2;
+ var pcm = BuildPcm16(sampleRate, channels, 3_072);
+ var wav = PcmCodec.ToWavBlob(pcm, channels, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var ac3 = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ ac3,
+ new Ac3FormatDescriptor(),
+ new FormatCreateOptions(Method: "ac3") {
+ FormatSpecific = { ["bitrate"] = "192000", ["dialnorm"] = "-27" },
+ });
+
+ var encoded = ac3.ToArray();
+ Assert.That(encoded.AsSpan(0, 2).ToArray(), Is.EqualTo(new byte[] { 0x0B, 0x77 }));
+
+ ac3.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ ac3,
+ new Ac3FormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(channels));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.GreaterThanOrEqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ [Test]
+ public void WavToDtsToFlac_PreservesGeometryAndSignal() {
+ const int sampleRate = 48_000;
+ const int channels = 2;
+ var pcm = BuildPcm16(sampleRate, channels, 2_048);
+ var wav = PcmCodec.ToWavBlob(pcm, channels, sampleRate, 16);
+
+ using var input = new MemoryStream(wav, writable: false);
+ using var dts = new MemoryStream();
+ AudioConversionOperation.Convert(
+ input,
+ new WavFormatDescriptor(),
+ dts,
+ new DtsFormatDescriptor(),
+ new FormatCreateOptions(Method: "dts") {
+ FormatSpecific = { ["bitrate"] = "768000", ["subbands"] = "16" },
+ });
+
+ var encoded = dts.ToArray();
+ Assert.That(BinaryPrimitives.ReadUInt32BigEndian(encoded), Is.EqualTo(0x7FFE8001u));
+
+ dts.Position = 0;
+ using var flac = new MemoryStream();
+ AudioConversionOperation.Convert(
+ dts,
+ new DtsFormatDescriptor(),
+ flac,
+ new FlacFormatDescriptor(),
+ new FormatCreateOptions(Method: "flac"));
+
+ flac.Position = 0;
+ var decoded = new FlacFormatDescriptor().DecodePcm(flac);
+ Assert.Multiple(() => {
+ Assert.That(decoded.Format.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.Format.Channels, Is.EqualTo(channels));
+ Assert.That(decoded.Format.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedData.Length, Is.GreaterThanOrEqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedData.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ var sample = (short)Math.Round(Math.Sin(2.0 * Math.PI * (440.0 + channel * 110.0) * frame / sampleRate) * 9_000.0);
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan((frame * channels + channel) * 2, 2), sample);
+ }
+ return pcm;
+ }
+}
diff --git a/Compression.Tests/Audio/WavCompressedConversionTests.cs b/Compression.Tests/Audio/WavCompressedConversionTests.cs
new file mode 100644
index 000000000..90b603b34
--- /dev/null
+++ b/Compression.Tests/Audio/WavCompressedConversionTests.cs
@@ -0,0 +1,90 @@
+using System.Buffers.Binary;
+using Codec.Pcm;
+using Compression.Lib;
+using Compression.Registry;
+using FileFormat.Wav;
+using NUnit.Framework;
+
+namespace Compression.Tests.Audio;
+
+[TestFixture]
+public sealed class WavCompressedConversionTests {
+
+ [TestCase("alaw", 0x0006)]
+ [TestCase("mulaw", 0x0007)]
+ [TestCase("ima-adpcm", 0x0011)]
+ [TestCase("ms-adpcm", 0x0002)]
+ public void WavToCompressedWav_WritesRealFormatAndDecodesToOriginalFrameCount(string codec, int formatTag) {
+ const int sampleRate = 8_000;
+ const int channels = 1;
+ const int frames = 1_603; // deliberately not aligned to ADPCM blocks
+ var pcm = BuildPcm16(sampleRate, channels, frames);
+ var source = PcmCodec.ToWavBlob(pcm, channels, sampleRate, 16);
+
+ using var input = new MemoryStream(source, writable: false);
+ using var output = new MemoryStream();
+ var options = new FormatCreateOptions(Method: codec);
+ if (codec is "ima-adpcm" or "ms-adpcm") options.FormatSpecific["block-align"] = "256";
+
+ var descriptor = new WavFormatDescriptor();
+ AudioConversionOperation.Convert(input, descriptor, output, descriptor, options);
+
+ var encoded = output.ToArray();
+ Assert.Multiple(() => {
+ Assert.That(ReadFormatTag(encoded), Is.EqualTo(formatTag));
+ Assert.That(ReadFactSampleFrames(encoded), Is.EqualTo((uint)frames));
+ });
+
+ var decoded = new WavReader().Read(encoded);
+ Assert.Multiple(() => {
+ Assert.That(decoded.FormatCode, Is.EqualTo(1));
+ Assert.That(decoded.SampleRate, Is.EqualTo(sampleRate));
+ Assert.That(decoded.NumChannels, Is.EqualTo(channels));
+ Assert.That(decoded.BitsPerSample, Is.EqualTo(16));
+ Assert.That(decoded.InterleavedPcm.Length, Is.EqualTo(pcm.Length));
+ Assert.That(decoded.InterleavedPcm.Any(static value => value != 0), Is.True);
+ });
+ }
+
+ [Test]
+ public void SameWavWithoutCodecRequest_RemainsByteExact() {
+ var pcm = BuildPcm16(44_100, 2, 1_024);
+ var source = PcmCodec.ToWavBlob(pcm, 2, 44_100, 16);
+ using var input = new MemoryStream(source, writable: false);
+ using var output = new MemoryStream();
+ var descriptor = new WavFormatDescriptor();
+
+ AudioConversionOperation.Convert(input, descriptor, output, descriptor);
+
+ Assert.That(output.ToArray(), Is.EqualTo(source));
+ }
+
+ private static ushort ReadFormatTag(ReadOnlySpan wav) {
+ var offset = FindChunk(wav, "fmt "u8);
+ return BinaryPrimitives.ReadUInt16LittleEndian(wav.Slice(offset + 8, 2));
+ }
+
+ private static uint ReadFactSampleFrames(ReadOnlySpan wav) {
+ var offset = FindChunk(wav, "fact"u8);
+ return BinaryPrimitives.ReadUInt32LittleEndian(wav.Slice(offset + 8, 4));
+ }
+
+ private static int FindChunk(ReadOnlySpan wav, ReadOnlySpan id) {
+ for (var offset = 12; offset + 8 <= wav.Length;) {
+ if (wav.Slice(offset, 4).SequenceEqual(id)) return offset;
+ var size = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(wav.Slice(offset + 4, 4)));
+ offset += 8 + size + (size & 1);
+ }
+ throw new InvalidDataException($"WAVE chunk '{System.Text.Encoding.ASCII.GetString(id)}' not found.");
+ }
+
+ private static byte[] BuildPcm16(int sampleRate, int channels, int frames) {
+ var pcm = new byte[frames * channels * 2];
+ for (var frame = 0; frame < frames; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ var sample = (short)Math.Round(Math.Sin(2.0 * Math.PI * (300.0 + channel * 170.0) * frame / sampleRate) * 12_000.0);
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan((frame * channels + channel) * 2, 2), sample);
+ }
+ return pcm;
+ }
+}
diff --git a/Directory.Build.props b/Directory.Build.props
index 2dec7b750..880fc65c9 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -26,4 +26,18 @@
true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FileFormats/FileFormat.Aac/AacFormatDescriptor.cs b/FileFormats/FileFormat.Aac/AacFormatDescriptor.cs
index aaa5dada6..90cce1ef1 100644
--- a/FileFormats/FileFormat.Aac/AacFormatDescriptor.cs
+++ b/FileFormats/FileFormat.Aac/AacFormatDescriptor.cs
@@ -1,39 +1,38 @@
#pragma warning disable CS1591
+using System.Buffers.Binary;
using Codec.Aac;
using Codec.Pcm;
using Compression.Registry;
+using FileFormat.Wav;
namespace FileFormat.Aac;
///
-/// Exposes an AAC (ADTS-framed) audio file as a pseudo-archive of FULL.aac
-/// (Kind Track) plus, when the bitstream can be decoded, one mono WAV per
-/// channel (LEFT.wav/RIGHT.wav/MONO.wav, Kind Channel).
-/// The decoder targets AAC-LC; for inputs it can't handle (Main/SSR/LTP/HE-AAC,
-/// or the not-yet-implemented spectral pipeline) the descriptor falls back to a
-/// FULL-only listing rather than failing.
+/// AAC-LC in ADTS framing. Besides the pseudo-archive view this descriptor exposes
+/// canonical PCM encode/decode and raw AAC access units for packet-preserving remux.
///
public sealed class AacFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations,
- IArchiveInMemoryExtract, IArchiveWriteConstraints {
+ IArchiveInMemoryExtract, IArchiveWriteConstraints, IArchiveCreatable,
+ IAudioContainerFormat, IAudioPcmSource, IAudioPcmTarget, IAudioDemuxSource {
+
+ private static readonly string[] EncodeCodecs = ["aac", "aac-lc"];
public string Id => "Aac";
public string DisplayName => "AAC (ADTS)";
public FormatCategory Category => FormatCategory.Audio;
public FormatCapabilities Capabilities =>
- FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest |
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanTest |
FormatCapabilities.SupportsMultipleEntries;
public string DefaultExtension => ".aac";
public IReadOnlyList Extensions => [".aac"];
public IReadOnlyList CompoundExtensions => [];
- // ADTS sync is a 12-bit 0xFFF word; the most common variant is MPEG-4, no CRC
- // (0xFFF1). Moderate confidence keeps false positives low for arbitrary streams.
public IReadOnlyList MagicSignatures => [
new([0xFF, 0xF1], Confidence: 0.40),
];
- public IReadOnlyList Methods => [new("aac", "AAC")];
+ public IReadOnlyList Methods => [new("aac", "AAC-LC / ADTS")];
public string? TarCompressionFormatId => null;
public AlgorithmFamily Family => AlgorithmFamily.Archive;
- public string Description => "AAC (ADTS) audio; full file + decoded per-channel PCM (AAC-LC).";
+ public string Description => "AAC-LC/ADTS audio; decode, encode, access-unit demux and per-channel PCM.";
public List List(Stream stream, string? password) =>
AudioPseudoArchive.List(BuildEntries(stream));
@@ -44,11 +43,9 @@ 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);
- // ── IArchiveWriteConstraints (no AAC encoder; archive-view inputs only) ──────
-
public long? MaxTotalArchiveSize => null;
public string AcceptedInputsDescription =>
- "AAC archive accepts: FULL.aac, LEFT/RIGHT/… .wav (per-channel)";
+ "AAC accepts FULL.aac or one/two mono PCM16 WAV channel files with matching sample rate and length.";
public bool CanAccept(ArchiveInputInfo input, out string? reason) {
var name = Path.GetFileName(input.ArchiveName).ToLowerInvariant();
@@ -56,53 +53,190 @@ public bool CanAccept(ArchiveInputInfo input, out string? reason) {
reason = null;
return true;
}
- reason = $"not an AAC-archive input (got {input.ArchiveName}); {AcceptedInputsDescription}";
+ reason = $"not an AAC input (got {input.ArchiveName}); {AcceptedInputsDescription}";
return false;
}
- // ── Shared archive-entry builder ─────────────────────────────────────────────
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(inputs);
+ ArgumentNullException.ThrowIfNull(options);
+
+ var files = FormatHelpers.FilesOnly(inputs).ToList();
+ var full = files.FirstOrDefault(static file =>
+ Path.GetFileName(file.Name).Equals("FULL.aac", StringComparison.OrdinalIgnoreCase));
+ if (full.Data is not null) {
+ output.Write(full.Data);
+ return;
+ }
- private static IReadOnlyList BuildEntries(Stream stream) {
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var blob = ms.ToArray();
+ var channels = files
+ .Where(static file => file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static file => ChannelLayout.OrderIndex(Path.GetFileNameWithoutExtension(file.Name)))
+ .Select(static file => new WavReader().Read(file.Data))
+ .ToArray();
+ if (channels.Length is < 1 or > 2)
+ throw new InvalidOperationException("AAC-LC creation requires one or two mono WAV channels.");
+
+ var first = channels[0];
+ if (first.NumChannels != 1 || first.FormatCode != 1 || first.BitsPerSample != 16)
+ throw new InvalidOperationException("AAC-LC creation requires PCM16 mono WAV inputs.");
+ if (channels.Any(channel => channel.NumChannels != 1 || channel.FormatCode != 1 ||
+ channel.BitsPerSample != 16 || channel.SampleRate != first.SampleRate ||
+ channel.InterleavedPcm.Length != first.InterleavedPcm.Length))
+ throw new InvalidOperationException("All AAC channel WAVs must be PCM16 mono with matching sample rate and frame count.");
+
+ var interleaved = PcmCodec.Interleave(channels.Select(static channel => channel.InterleavedPcm).ToList(), 16);
+ var pcm = new AudioPcmBuffer(
+ new AudioPcmFormat(first.SampleRate, channels.Length, 16),
+ interleaved);
+ var codec = options.Method ?? options.GetString("codec") ?? "aac";
+ this.EncodePcm(output, pcm, codec, options);
+ }
+
+ public IReadOnlyList SupportedEncodeCodecs => EncodeCodecs;
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!EncodeCodecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not AAC-LC";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "AAC-LC encoder input must be signed PCM16";
+ return false;
+ }
+ if (format.Channels is < 1 or > 2) {
+ reason = "AAC-LC encoder supports mono or stereo";
+ return false;
+ }
+ if (Array.IndexOf(AacAdtsReader.SampleRateTable, format.SampleRate) is < 0 or > 12) {
+ reason = "sample rate is not an ADTS standard rate";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ var samples = new short[pcm.InterleavedData.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(pcm.InterleavedData.AsSpan(i * 2, 2));
+
+ var bitrate = options.TryGetInt("bitrate", out var configuredBitrate)
+ ? configuredBitrate
+ : pcm.Format.Channels == 1 ? 64_000 : 128_000;
+ var cutoff = options.TryGetInt("cutoff", out var configuredCutoff) ? configuredCutoff : 0;
+ var window = options.GetString("window")?.ToLowerInvariant() switch {
+ "kbd" => AacEncoderWindowShape.Kbd,
+ _ => AacEncoderWindowShape.Sine,
+ };
+ var stereoMode = options.GetString("stereo-mode")?.ToLowerInvariant() switch {
+ "independent" => AacStereoCodingMode.Independent,
+ "ms" or "mid-side" or "midside" => AacStereoCodingMode.MidSide,
+ _ => AacStereoCodingMode.Auto,
+ };
+ var pad = options.GetString("pad-final-frame") is { } padText
+ ? bool.Parse(padText)
+ : true;
+ var encoded = AacEncoder.Encode(samples, new AacEncoderOptions(
+ pcm.Format.SampleRate, pcm.Format.Channels, bitrate, cutoff, window, stereoMode, pad));
+ output.Write(encoded);
+ }
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ var data = ReadAll(input);
+ using var probe = new MemoryStream(data, writable: false);
+ var info = AacCodec.ReadStreamInfo(probe);
+ using var rateProbe = new MemoryStream(data, writable: false);
+ var sampleRate = AacCodec.ReadCoreSampleRate(rateProbe);
+ using var source = new MemoryStream(data, writable: false);
+ using var pcm = new MemoryStream();
+ AacCodec.Decompress(source, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(sampleRate, info.Channels, 16),
+ pcm.ToArray());
+ }
+
+ public bool TryDemux(Stream input, out AudioEncodedStream? stream) {
+ ArgumentNullException.ThrowIfNull(input);
+ var data = ReadAll(input);
+ var packets = new List();
+ AdtsHeader? first = null;
+ var offset = 0;
+ try {
+ while (offset + AacAdtsReader.ShortHeaderLength <= data.Length) {
+ var header = AacAdtsReader.ParseHeader(data, offset);
+ if (header.FrameLength < header.HeaderLengthBytes || offset + header.FrameLength > data.Length)
+ throw new InvalidDataException("ADTS frame overruns input.");
+ first ??= header;
+ var payloadLength = header.FrameLength - header.HeaderLengthBytes;
+ packets.Add(new AudioPacket(
+ data.AsSpan(offset + header.HeaderLengthBytes, payloadLength).ToArray(),
+ DurationSamples: (header.NumberOfRawDataBlocks + 1L) * AacEncoder.FrameSamples));
+ offset += header.FrameLength;
+ }
+ } catch (InvalidDataException) {
+ stream = null;
+ return false;
+ }
+
+ if (first is not { } initial || packets.Count == 0 || offset != data.Length) {
+ stream = null;
+ return false;
+ }
+
+ var objectType = initial.Profile + 1;
+ var asc = new byte[2];
+ asc[0] = (byte)((objectType << 3) | (initial.SampleRateIndex >> 1));
+ asc[1] = (byte)(((initial.SampleRateIndex & 1) << 7) | (initial.ChannelConfiguration << 3));
+ stream = new AudioEncodedStream(
+ new AudioStreamFormat(
+ "aac",
+ initial.SampleRate,
+ initial.ChannelConfiguration,
+ Properties: new Dictionary(StringComparer.OrdinalIgnoreCase) {
+ ["object-type"] = objectType.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["sample-rate-index"] = initial.SampleRateIndex.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ }),
+ packets,
+ asc);
+ return true;
+ }
+
+ private static IReadOnlyList BuildEntries(Stream stream) {
+ var blob = ReadAll(stream);
var entries = new List {
new("FULL.aac", "Container", blob, "aac"),
};
- // Best-effort decode-and-split. The decoder targets AAC-LC and throws
- // NotSupportedException for other profiles (and InvalidDataException for
- // malformed input); in every failure case we keep the FULL-only listing.
try {
- using var probe = new MemoryStream(blob, writable: false);
- var info = AacCodec.ReadStreamInfo(probe);
-
- using var src = new MemoryStream(blob, writable: false);
- using var pcm = new MemoryStream();
- AacCodec.Decompress(src, pcm);
- var pcmBytes = pcm.ToArray();
-
- // The decoded PCM is the AAC-LC core band, so the WAV must carry the CORE
- // sample rate to play at the right speed. For HE-AAC streams info.SampleRate
- // is the SBR-doubled effective rate (surfaced as metadata); SBR audio
- // reconstruction is gated, so we deliberately do not retime the core PCM.
- using var rateProbe = new MemoryStream(blob, writable: false);
- var coreRate = AacCodec.ReadCoreSampleRate(rateProbe);
-
- const int bitsPerSample = 16;
- if (info.Channels <= 1) {
+ var descriptor = new AacFormatDescriptor();
+ using var source = new MemoryStream(blob, writable: false);
+ var pcm = descriptor.DecodePcm(source);
+ if (pcm.Format.Channels <= 1) {
entries.Add(new("MONO.wav", "Channel",
- PcmCodec.ToWavBlob(pcmBytes, 1, coreRate, bitsPerSample, formatCode: 1), "pcm"));
+ PcmCodec.ToWavBlob(pcm.InterleavedData, 1, pcm.Format.SampleRate, 16, formatCode: 1), "pcm"));
} else {
foreach (var (name, wav) in PcmCodec.SplitInterleavedPcm(
- pcmBytes, info.Channels, coreRate, bitsPerSample))
+ pcm.InterleavedData, pcm.Format.Channels, pcm.Format.SampleRate, 16))
entries.Add(new($"{name}.wav", "Channel", wav, "pcm"));
}
} catch (Exception) {
- // Graceful fallback: surface the original AAC file only.
+ // Graceful archive-view fallback for unsupported/malformed AAC.
}
return entries;
}
+
+ private static byte[] ReadAll(Stream input) {
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ return memory.ToArray();
+ }
}
diff --git a/FileFormats/FileFormat.Aac/FileFormat.Aac.csproj b/FileFormats/FileFormat.Aac/FileFormat.Aac.csproj
index c254e6276..88dafcca1 100644
--- a/FileFormats/FileFormat.Aac/FileFormat.Aac.csproj
+++ b/FileFormats/FileFormat.Aac/FileFormat.Aac.csproj
@@ -9,6 +9,7 @@
+
diff --git a/FileFormats/FileFormat.AmrNb/AmrNbFormatDescriptor.cs b/FileFormats/FileFormat.AmrNb/AmrNbFormatDescriptor.cs
new file mode 100644
index 000000000..273a0d368
--- /dev/null
+++ b/FileFormats/FileFormat.AmrNb/AmrNbFormatDescriptor.cs
@@ -0,0 +1,186 @@
+#pragma warning disable CS1591
+using System.Buffers.Binary;
+using Codec.AmrNb;
+using Compression.Registry;
+
+namespace FileFormat.AmrNb;
+
+/// 3GPP AMR-NB single-channel storage format (#!AMR\n + IF1 storage frames).
+public sealed class AmrNbFormatDescriptor : IFormatDescriptor, IAudioContainerFormat,
+ IAudioPcmSource, IAudioPcmTarget, IAudioDemuxSource, IAudioMuxTarget {
+
+ private static readonly byte[] FileMagic = "#!AMR\n"u8.ToArray();
+ private static readonly string[] Codecs = ["amr-nb", "amrnb"];
+
+ public string Id => "AmrNb";
+ public string DisplayName => "AMR-NB";
+ public FormatCategory Category => FormatCategory.Audio;
+ public FormatCapabilities Capabilities => FormatCapabilities.None;
+ public string DefaultExtension => ".amr";
+ public IReadOnlyList Extensions => [".amr"];
+ public IReadOnlyList CompoundExtensions => [];
+ public IReadOnlyList MagicSignatures => [new(FileMagic, Confidence: 0.99)];
+ public IReadOnlyList Methods => [new("amr-nb", "AMR-NB")];
+ public string? TarCompressionFormatId => null;
+ public AlgorithmFamily Family => AlgorithmFamily.Archive;
+ public string Description => "3GPP AMR-NB storage file; #!AMR magic followed by IF1 storage frames.";
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+ public IReadOnlyList SupportedMuxCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ var storage = ReadStorage(input);
+ ValidateStorage(storage);
+ var samples = AmrNbCodec.Decode(storage);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(AmrNbCodec.SampleRate, 1, 16, AudioPcmEncoding.SignedInteger),
+ ToLittleEndian(samples));
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!IsCodec(codecId)) {
+ reason = $"AMR-NB does not support codec '{codecId}'.";
+ return false;
+ }
+ if (format.SampleRate != AmrNbCodec.SampleRate || format.Channels != 1 ||
+ format.BitsPerSample != 16 || format.Encoding != AudioPcmEncoding.SignedInteger) {
+ reason = "AMR-NB encoding requires mono signed PCM16 at 8000 Hz.";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ var encoderOptions = new AmrNbEncoderOptions(
+ ParseMode(options.GetOption("mode", "12.2")),
+ options.GetOptionBool("dtx", false),
+ options.GetOptionBool("pad-final-frame", true));
+ var encoded = AmrNbCodec.Encode(ReadPcm16(pcm.InterleavedData), encoderOptions);
+ output.Write(FileMagic);
+ output.Write(encoded);
+ }
+
+ public bool TryDemux(Stream input, out AudioEncodedStream? stream) {
+ stream = null;
+ try {
+ var storage = ReadStorage(input);
+ var packets = ParsePackets(storage);
+ stream = new AudioEncodedStream(
+ new AudioStreamFormat("amr-nb", AmrNbCodec.SampleRate, 1, 16),
+ packets);
+ return true;
+ } catch (InvalidDataException) {
+ return false;
+ }
+ }
+
+ public bool CanMux(AudioStreamFormat stream, FormatCreateOptions options, out string? reason) {
+ if (!IsCodec(stream.CodecId)) {
+ reason = $"AMR-NB cannot mux codec '{stream.CodecId}'.";
+ return false;
+ }
+ if (stream.SampleRate != AmrNbCodec.SampleRate || stream.Channels != 1) {
+ reason = "AMR-NB storage requires mono 8000 Hz access units.";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void Mux(Stream output, AudioEncodedStream stream, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanMux(stream.Format, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ output.Write(FileMagic);
+ foreach (var packet in stream.Packets) {
+ if (packet.IsHeader)
+ throw new InvalidDataException("AMR-NB file magic is container metadata, not an encoded packet.");
+ ValidateSingleFrame(packet.Data);
+ output.Write(packet.Data);
+ }
+ }
+
+ private static byte[] ReadStorage(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ var file = memory.ToArray();
+ if (file.Length < FileMagic.Length || !file.AsSpan(0, FileMagic.Length).SequenceEqual(FileMagic))
+ throw new InvalidDataException("Missing AMR-NB '#!AMR\\n' file magic.");
+ return file[FileMagic.Length..];
+ }
+
+ private static IReadOnlyList ParsePackets(ReadOnlySpan storage) {
+ var packets = new List();
+ var pos = 0;
+ while (pos < storage.Length) {
+ var size = FrameSize(storage[pos]);
+ if (pos + size > storage.Length)
+ throw new InvalidDataException("Truncated AMR-NB storage frame.");
+ packets.Add(new AudioPacket(storage.Slice(pos, size).ToArray(), AmrNbCodec.SamplesPerFrame));
+ pos += size;
+ }
+ return packets;
+ }
+
+ private static void ValidateStorage(ReadOnlySpan storage) => _ = ParsePackets(storage);
+
+ private static void ValidateSingleFrame(ReadOnlySpan frame) {
+ if (frame.IsEmpty)
+ throw new InvalidDataException("Empty AMR-NB packet.");
+ var size = FrameSize(frame[0]);
+ if (frame.Length != size)
+ throw new InvalidDataException($"AMR-NB packet has {frame.Length} bytes; frame type requires {size}.");
+ }
+
+ private static int FrameSize(byte header) {
+ if ((header & 0x83) != 0)
+ throw new InvalidDataException("AMR-NB storage frame has non-zero padding bits.");
+ var frameType = (header >> 3) & 0x0F;
+ if (frameType is > 8 and < 15)
+ throw new InvalidDataException($"Reserved AMR-NB frame type {frameType} is not valid in an AMR-NB storage file.");
+ return 1 + AmrNbCodec.PayloadBytes(frameType);
+ }
+
+ private static bool IsCodec(string codecId)
+ => Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase);
+
+ private static AmrNbMode ParseMode(string text) => text.Trim().ToLowerInvariant() switch {
+ "4.75" or "475" or "mr475" => AmrNbMode.Mr475,
+ "5.15" or "515" or "mr515" => AmrNbMode.Mr515,
+ "5.90" or "5.9" or "590" or "59" or "mr59" => AmrNbMode.Mr59,
+ "6.70" or "6.7" or "670" or "67" or "mr67" => AmrNbMode.Mr67,
+ "7.40" or "7.4" or "740" or "74" or "mr74" => AmrNbMode.Mr74,
+ "7.95" or "795" or "mr795" => AmrNbMode.Mr795,
+ "10.2" or "102" or "mr102" => AmrNbMode.Mr102,
+ "12.2" or "122" or "mr122" => AmrNbMode.Mr122,
+ _ => throw new ArgumentException($"Unknown AMR-NB mode '{text}'. Expected 4.75, 5.15, 5.90, 6.70, 7.40, 7.95, 10.2, or 12.2 kbit/s."),
+ };
+
+ private static short[] ReadPcm16(ReadOnlySpan data) {
+ if ((data.Length & 1) != 0)
+ throw new InvalidDataException("PCM16 payload has odd length.");
+ var samples = new short[data.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
+ return samples;
+ }
+
+ private static byte[] ToLittleEndian(ReadOnlySpan samples) {
+ var bytes = new byte[samples.Length * 2];
+ for (var i = 0; i < samples.Length; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * 2, 2), samples[i]);
+ return bytes;
+ }
+}
diff --git a/FileFormats/FileFormat.AmrNb/FileFormat.AmrNb.csproj b/FileFormats/FileFormat.AmrNb/FileFormat.AmrNb.csproj
new file mode 100644
index 000000000..60ab4cad6
--- /dev/null
+++ b/FileFormats/FileFormat.AmrNb/FileFormat.AmrNb.csproj
@@ -0,0 +1,13 @@
+
+
+
+ FileFormat.AmrNb
+
+
+
+
+
+
+
+
+
diff --git a/FileFormats/FileFormat.AmrWb/AmrWbFormatDescriptor.cs b/FileFormats/FileFormat.AmrWb/AmrWbFormatDescriptor.cs
new file mode 100644
index 000000000..4b1fea0d0
--- /dev/null
+++ b/FileFormats/FileFormat.AmrWb/AmrWbFormatDescriptor.cs
@@ -0,0 +1,187 @@
+#pragma warning disable CS1591
+using System.Buffers.Binary;
+using Codec.AmrWb;
+using Compression.Registry;
+
+namespace FileFormat.AmrWb;
+
+/// 3GPP AMR-WB single-channel storage format (#!AMR-WB\n + storage frames).
+public sealed class AmrWbFormatDescriptor : IFormatDescriptor, IAudioContainerFormat,
+ IAudioPcmSource, IAudioPcmTarget, IAudioDemuxSource, IAudioMuxTarget {
+
+ private static readonly byte[] FileMagic = "#!AMR-WB\n"u8.ToArray();
+ private static readonly string[] Codecs = ["amr-wb", "amrwb"];
+
+ public string Id => "AmrWb";
+ public string DisplayName => "AMR-WB";
+ public FormatCategory Category => FormatCategory.Audio;
+ public FormatCapabilities Capabilities => FormatCapabilities.None;
+ public string DefaultExtension => ".amr";
+ public IReadOnlyList Extensions => [".amr"];
+ public IReadOnlyList CompoundExtensions => [];
+ public IReadOnlyList MagicSignatures => [new(FileMagic, Confidence: 0.99)];
+ public IReadOnlyList Methods => [new("amr-wb", "AMR-WB")];
+ public string? TarCompressionFormatId => null;
+ public AlgorithmFamily Family => AlgorithmFamily.Archive;
+ public string Description => "3GPP AMR-WB storage file; #!AMR-WB magic followed by storage frames.";
+
+ public IReadOnlyList SupportedEncodeCodecs => Codecs;
+ public IReadOnlyList SupportedMuxCodecs => Codecs;
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ var storage = ReadStorage(input);
+ ValidateStorage(storage);
+ var samples = AmrWbCodec.Decode(storage);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(AmrWbCodec.SampleRate, 1, 16, AudioPcmEncoding.SignedInteger),
+ ToLittleEndian(samples));
+ }
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!IsCodec(codecId)) {
+ reason = $"AMR-WB does not support codec '{codecId}'.";
+ return false;
+ }
+ if (format.SampleRate != AmrWbCodec.SampleRate || format.Channels != 1 ||
+ format.BitsPerSample != 16 || format.Encoding != AudioPcmEncoding.SignedInteger) {
+ reason = "AMR-WB encoding requires mono signed PCM16 at 16000 Hz.";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(pcm);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ var encoderOptions = new AmrWbEncoderOptions(
+ ParseMode(options.GetOption("mode", "12.65")),
+ options.GetOptionBool("dtx", false),
+ options.GetOptionBool("pad-final-frame", true));
+ var encoded = AmrWbCodec.Encode(ReadPcm16(pcm.InterleavedData), encoderOptions);
+ output.Write(FileMagic);
+ output.Write(encoded);
+ }
+
+ public bool TryDemux(Stream input, out AudioEncodedStream? stream) {
+ stream = null;
+ try {
+ var storage = ReadStorage(input);
+ var packets = ParsePackets(storage);
+ stream = new AudioEncodedStream(
+ new AudioStreamFormat("amr-wb", AmrWbCodec.SampleRate, 1, 16),
+ packets);
+ return true;
+ } catch (InvalidDataException) {
+ return false;
+ }
+ }
+
+ public bool CanMux(AudioStreamFormat stream, FormatCreateOptions options, out string? reason) {
+ if (!IsCodec(stream.CodecId)) {
+ reason = $"AMR-WB cannot mux codec '{stream.CodecId}'.";
+ return false;
+ }
+ if (stream.SampleRate != AmrWbCodec.SampleRate || stream.Channels != 1) {
+ reason = "AMR-WB storage requires mono 16000 Hz access units.";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void Mux(Stream output, AudioEncodedStream stream, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanMux(stream.Format, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ output.Write(FileMagic);
+ foreach (var packet in stream.Packets) {
+ if (packet.IsHeader)
+ throw new InvalidDataException("AMR-WB file magic is container metadata, not an encoded packet.");
+ ValidateSingleFrame(packet.Data);
+ output.Write(packet.Data);
+ }
+ }
+
+ private static byte[] ReadStorage(Stream input) {
+ ArgumentNullException.ThrowIfNull(input);
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ var file = memory.ToArray();
+ if (file.Length < FileMagic.Length || !file.AsSpan(0, FileMagic.Length).SequenceEqual(FileMagic))
+ throw new InvalidDataException("Missing AMR-WB '#!AMR-WB\\n' file magic.");
+ return file[FileMagic.Length..];
+ }
+
+ private static IReadOnlyList ParsePackets(ReadOnlySpan storage) {
+ var packets = new List();
+ var pos = 0;
+ while (pos < storage.Length) {
+ var size = FrameSize(storage[pos]);
+ if (pos + size > storage.Length)
+ throw new InvalidDataException("Truncated AMR-WB storage frame.");
+ packets.Add(new AudioPacket(storage.Slice(pos, size).ToArray(), AmrWbCodec.SamplesPerFrame));
+ pos += size;
+ }
+ return packets;
+ }
+
+ private static void ValidateStorage(ReadOnlySpan storage) => _ = ParsePackets(storage);
+
+ private static void ValidateSingleFrame(ReadOnlySpan frame) {
+ if (frame.IsEmpty)
+ throw new InvalidDataException("Empty AMR-WB packet.");
+ var size = FrameSize(frame[0]);
+ if (frame.Length != size)
+ throw new InvalidDataException($"AMR-WB packet has {frame.Length} bytes; frame type requires {size}.");
+ }
+
+ private static int FrameSize(byte header) {
+ if ((header & 0x83) != 0)
+ throw new InvalidDataException("AMR-WB storage frame has non-zero padding bits.");
+ var frameType = (header >> 3) & 0x0F;
+ if (frameType is >= 10 and <= 13)
+ throw new InvalidDataException($"Reserved AMR-WB frame type {frameType} is not valid in an AMR-WB storage file.");
+ return Math.Max(1, AmrWbCodec.FrameBytes(frameType));
+ }
+
+ private static bool IsCodec(string codecId)
+ => Codecs.Contains(codecId, StringComparer.OrdinalIgnoreCase);
+
+ private static AmrWbMode ParseMode(string text) => text.Trim().ToLowerInvariant() switch {
+ "6.60" or "6.6" or "660" or "mr660" => AmrWbMode.Mr660,
+ "8.85" or "885" or "mr885" => AmrWbMode.Mr885,
+ "12.65" or "1265" or "mr1265" => AmrWbMode.Mr1265,
+ "14.25" or "1425" or "mr1425" => AmrWbMode.Mr1425,
+ "15.85" or "1585" or "mr1585" => AmrWbMode.Mr1585,
+ "18.25" or "1825" or "mr1825" => AmrWbMode.Mr1825,
+ "19.85" or "1985" or "mr1985" => AmrWbMode.Mr1985,
+ "23.05" or "2305" or "mr2305" => AmrWbMode.Mr2305,
+ "23.85" or "2385" or "mr2385" => AmrWbMode.Mr2385,
+ _ => throw new ArgumentException($"Unknown AMR-WB mode '{text}'. Expected 6.60, 8.85, 12.65, 14.25, 15.85, 18.25, 19.85, 23.05, or 23.85 kbit/s."),
+ };
+
+ private static short[] ReadPcm16(ReadOnlySpan data) {
+ if ((data.Length & 1) != 0)
+ throw new InvalidDataException("PCM16 payload has odd length.");
+ var samples = new short[data.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
+ return samples;
+ }
+
+ private static byte[] ToLittleEndian(ReadOnlySpan samples) {
+ var bytes = new byte[samples.Length * 2];
+ for (var i = 0; i < samples.Length; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(bytes.AsSpan(i * 2, 2), samples[i]);
+ return bytes;
+ }
+}
diff --git a/FileFormats/FileFormat.AmrWb/FileFormat.AmrWb.csproj b/FileFormats/FileFormat.AmrWb/FileFormat.AmrWb.csproj
new file mode 100644
index 000000000..8d8b18a95
--- /dev/null
+++ b/FileFormats/FileFormat.AmrWb/FileFormat.AmrWb.csproj
@@ -0,0 +1,13 @@
+
+
+
+ FileFormat.AmrWb
+
+
+
+
+
+
+
+
+
diff --git a/FileFormats/FileFormat.Caf/CafFormatDescriptor.cs b/FileFormats/FileFormat.Caf/CafFormatDescriptor.cs
index 57e8a4e5d..8bad6400c 100644
--- a/FileFormats/FileFormat.Caf/CafFormatDescriptor.cs
+++ b/FileFormats/FileFormat.Caf/CafFormatDescriptor.cs
@@ -1,6 +1,5 @@
#pragma warning disable CS1591
using System.Buffers.Binary;
-using System.Text;
using Codec.Pcm;
using Compression.Registry;
using FileFormat.Wav;
@@ -8,12 +7,8 @@
namespace FileFormat.Caf;
///
-/// Exposes an Apple Core Audio Format (.caf) file as an archive of
-/// FULL.caf plus one mono WAV per channel (for LPCM integer audio, or the
-/// G.711 ulaw/alaw companded formats decoded to 16-bit PCM) plus any
-/// ancillary chunks (info, chan, free, …) as
-/// metadata/<type>.bin. Float LPCM and other compressed formats
-/// (ima4, aac , …) are surfaced as FULL.caf only.
+/// Exposes an Apple Core Audio Format (.caf) file as a pseudo-archive and
+/// creates fresh LPCM CAF files from canonical per-channel WAV inputs.
///
public sealed class CafFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations,
IArchiveInMemoryExtract, IArchiveWriteConstraints, IArchiveCreatable {
@@ -22,14 +17,12 @@ public sealed class CafFormatDescriptor : IFormatDescriptor, IArchiveFormatOpera
public string DisplayName => "CAF (Core Audio Format)";
public FormatCategory Category => FormatCategory.Audio;
public FormatCapabilities Capabilities =>
- FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest |
- FormatCapabilities.SupportsMultipleEntries;
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate |
+ FormatCapabilities.CanTest | FormatCapabilities.SupportsMultipleEntries;
public string DefaultExtension => ".caf";
public IReadOnlyList Extensions => [".caf"];
public IReadOnlyList CompoundExtensions => [];
- public IReadOnlyList MagicSignatures => [
- new("caff"u8.ToArray(), Confidence: 0.90),
- ];
+ public IReadOnlyList MagicSignatures => [new("caff"u8.ToArray(), Confidence: 0.90)];
public IReadOnlyList Methods => [new("stored", "Stored")];
public string? TarCompressionFormatId => null;
public AlgorithmFamily Family => AlgorithmFamily.Archive;
@@ -44,130 +37,120 @@ 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);
- // ── IArchiveCreatable: assemble a CAF from per-channel mono WAVs ──────────────
-
public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) {
- var fileList = FormatHelpers.FilesOnly(inputs).ToList();
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(inputs);
+ ArgumentNullException.ThrowIfNull(options);
- // Passthrough a provided FULL.caf verbatim (archive-view semantics).
- var full = fileList.FirstOrDefault(f =>
- Path.GetFileName(f.Name).Equals("FULL.caf", StringComparison.OrdinalIgnoreCase));
- if (full.Data != null) {
+ var fileList = FormatHelpers.FilesOnly(inputs).ToList();
+ var full = fileList.FirstOrDefault(static file =>
+ Path.GetFileName(file.Name).Equals("FULL.caf", StringComparison.OrdinalIgnoreCase));
+ if (full.Data is not null) {
output.Write(full.Data);
return;
}
var channelBlobs = fileList
- .Where(f => {
- var name = Path.GetFileName(f.Name);
- return name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase) &&
- !name.Equals("FULL.caf", StringComparison.OrdinalIgnoreCase);
- })
- .OrderBy(f => ChannelOrder(Path.GetFileNameWithoutExtension(f.Name)))
- .ToList();
-
- if (channelBlobs.Count == 0)
+ .Where(static file => Path.GetFileName(file.Name).EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static file => ChannelLayout.OrderIndex(Path.GetFileNameWithoutExtension(file.Name)))
+ .ToArray();
+ if (channelBlobs.Length == 0)
throw new InvalidOperationException("CAF archive create needs either FULL.caf or one or more per-channel WAVs.");
- var channels = new List();
- foreach (var (_, data) in channelBlobs) channels.Add(new WavReader().Read(data));
-
+ var channels = channelBlobs.Select(static file => new WavReader().Read(file.Data)).ToArray();
var first = channels[0];
- if (channels.Any(c => c.SampleRate != first.SampleRate || c.BitsPerSample != first.BitsPerSample || c.NumChannels != 1))
- throw new InvalidOperationException("All channel WAVs must be mono and share sample rate + bit depth.");
-
- var bytesPerSample = first.BitsPerSample / 8;
- var frameCount = first.InterleavedPcm.Length / bytesPerSample;
- if (channels.Any(c => c.InterleavedPcm.Length / bytesPerSample != frameCount))
+ if (channels.Any(channel => channel.SampleRate != first.SampleRate ||
+ channel.BitsPerSample != first.BitsPerSample ||
+ channel.FormatCode != first.FormatCode || channel.NumChannels != 1))
+ throw new InvalidOperationException("All channel WAVs must be mono and share sample rate, sample type, and bit depth.");
+ if (channels.Any(channel => channel.InterleavedPcm.Length != first.InterleavedPcm.Length))
throw new InvalidOperationException("All channel WAVs must have the same frame count.");
- var interleaved = PcmCodec.Interleave(channels.Select(c => c.InterleavedPcm).ToList(), first.BitsPerSample);
-
- WriteCaf(output, channels.Count, first.SampleRate, first.BitsPerSample, interleaved);
+ var interleaved = PcmCodec.Interleave(channels.Select(static channel => channel.InterleavedPcm).ToList(), first.BitsPerSample);
+ WriteCaf(output, channels.Length, first.SampleRate, first.BitsPerSample, first.FormatCode == 3, interleaved);
}
- ///
- /// Writes a valid LPCM CAF. The little-endian flag is set in mFormatFlags so the
- /// interleaved little-endian PCM (the canonical buffer used throughout this codebase) can
- /// be written verbatim without byte-swapping; honours that flag.
- ///
- private static void WriteCaf(Stream output, int channels, int sampleRate, int bitsPerChannel, byte[] interleaved) {
- Span hdr = stackalloc byte[8];
- "caff"u8.CopyTo(hdr);
- BinaryPrimitives.WriteUInt16BigEndian(hdr[4..], 1); // mFileVersion
- BinaryPrimitives.WriteUInt16BigEndian(hdr[6..], 0); // mFileFlags
- output.Write(hdr);
-
- var bytesPerFrame = (uint)(channels * bitsPerChannel / 8);
- var desc = new byte[32];
- BinaryPrimitives.WriteDoubleBigEndian(desc.AsSpan(0), sampleRate);
- "lpcm"u8.CopyTo(desc.AsSpan(8));
- BinaryPrimitives.WriteUInt32BigEndian(desc.AsSpan(12), FlagIsLittleEndian); // integer, little-endian samples
- BinaryPrimitives.WriteUInt32BigEndian(desc.AsSpan(16), bytesPerFrame); // mBytesPerPacket
- BinaryPrimitives.WriteUInt32BigEndian(desc.AsSpan(20), 1); // mFramesPerPacket
- BinaryPrimitives.WriteUInt32BigEndian(desc.AsSpan(24), (uint)channels); // mChannelsPerFrame
- BinaryPrimitives.WriteUInt32BigEndian(desc.AsSpan(28), (uint)bitsPerChannel); // mBitsPerChannel
- WriteChunk(output, "desc", desc);
-
- var dataBody = new byte[4 + interleaved.Length]; // 4-byte mEditCount (0) + audio
- interleaved.CopyTo(dataBody.AsSpan(4));
- WriteChunk(output, "data", dataBody);
+ /// Writes standards-compliant little-endian packed LPCM CAF.
+ internal static void WriteCaf(Stream output, int channels, int sampleRate, int bitsPerChannel,
+ bool isFloat, ReadOnlySpan interleaved) {
+ Span header = stackalloc byte[8];
+ "caff"u8.CopyTo(header);
+ BinaryPrimitives.WriteUInt16BigEndian(header[4..], 1);
+ BinaryPrimitives.WriteUInt16BigEndian(header[6..], 0);
+ output.Write(header);
+
+ var bytesPerFrame = checked((uint)(channels * ((bitsPerChannel + 7) / 8)));
+ Span desc = stackalloc byte[32];
+ BinaryPrimitives.WriteDoubleBigEndian(desc, sampleRate);
+ "lpcm"u8.CopyTo(desc[8..]);
+ var flags = isFloat ? FlagIsFloat | FlagIsPacked : FlagIsSignedInteger | FlagIsPacked;
+ BinaryPrimitives.WriteUInt32BigEndian(desc[12..], flags);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[16..], bytesPerFrame);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[20..], 1);
+ BinaryPrimitives.WriteUInt32BigEndian(desc[24..], checked((uint)channels));
+ BinaryPrimitives.WriteUInt32BigEndian(desc[28..], checked((uint)bitsPerChannel));
+ WriteChunk(output, "desc"u8, desc);
+
+ var data = new byte[4 + interleaved.Length];
+ interleaved.CopyTo(data.AsSpan(4));
+ WriteChunk(output, "data"u8, data);
}
- private const uint FlagIsLittleEndian = 0x2;
-
- private static void WriteChunk(Stream s, string type, byte[] body) {
- Span head = stackalloc byte[12];
- Encoding.ASCII.GetBytes(type).CopyTo(head);
- BinaryPrimitives.WriteInt64BigEndian(head[4..], body.Length);
- s.Write(head);
- s.Write(body);
+ private const uint FlagIsFloat = 0x1;
+ private const uint FlagIsSignedInteger = 0x4;
+ private const uint FlagIsPacked = 0x8;
+
+ internal static void WriteChunk(Stream output, ReadOnlySpan type, ReadOnlySpan body) {
+ if (type.Length != 4) throw new ArgumentException("CAF chunk type must be four bytes.", nameof(type));
+ Span header = stackalloc byte[12];
+ type.CopyTo(header);
+ BinaryPrimitives.WriteInt64BigEndian(header[4..], body.Length);
+ output.Write(header);
+ output.Write(body);
}
- // Canonical speaker ordering (FFmpeg/WAVE bit order, mono through 22.2).
- private static int ChannelOrder(string name) => ChannelLayout.OrderIndex(name);
-
- // ── IArchiveWriteConstraints ──────────────────────────────────────────────
-
public long? MaxTotalArchiveSize => null;
public string AcceptedInputsDescription =>
"CAF archive accepts: FULL.caf, LEFT/RIGHT/CENTER/… .wav (per-channel), metadata/*.bin";
public bool CanAccept(ArchiveInputInfo input, out string? reason) {
var name = Path.GetFileName(input.ArchiveName).ToLowerInvariant();
- var dir = Path.GetDirectoryName(input.ArchiveName)?.Replace('\\', '/').ToLowerInvariant() ?? "";
-
- if (dir == "" && (name == "full.caf" || name.EndsWith(".wav"))) { reason = null; return true; }
- if (dir == "metadata" && name.EndsWith(".bin")) { reason = null; return true; }
- reason = $"not a CAF-archive input (got {input.ArchiveName}); {AcceptedInputsDescription}";
+ var directory = Path.GetDirectoryName(input.ArchiveName)?.Replace('\\', '/').ToLowerInvariant() ?? "";
+ if (directory.Length == 0 && (name == "full.caf" || name.EndsWith(".wav"))) {
+ reason = null;
+ return true;
+ }
+ if (directory == "metadata" && name.EndsWith(".bin")) {
+ reason = null;
+ return true;
+ }
+ reason = $"not a CAF-archive input (got {input.ArchiveName}); {this.AcceptedInputsDescription}";
return false;
}
private static IReadOnlyList BuildEntries(Stream stream) {
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var blob = ms.ToArray();
+ using var memory = new MemoryStream();
+ stream.CopyTo(memory);
+ var blob = memory.ToArray();
var parsed = new CafReader().Read(blob);
-
- var entries = new List {
- new("FULL.caf", "Container", blob),
- };
-
- // Split integer LPCM per-channel; float and non-LPCM are surfaced as FULL only.
- if (!parsed.IsFloat &&
- parsed.FormatId == "lpcm" &&
- parsed.BitsPerSample is 8 or 16 or 24 or 32 &&
- parsed.NumChannels >= 1 &&
- parsed.InterleavedPcm.Length > 0) {
- foreach (var (name, wavBlob) in PcmCodec.SplitInterleavedPcm(
- parsed.InterleavedPcm, parsed.NumChannels, parsed.SampleRate, parsed.BitsPerSample,
- parsed.ChannelMask))
- entries.Add(new($"{name}.wav", "Channel", wavBlob, "pcm"));
+ var entries = new List { new("FULL.caf", "Container", blob) };
+
+ if (parsed.FormatId == "lpcm" && parsed.BitsPerSample is 8 or 16 or 24 or 32 or 64 &&
+ parsed.NumChannels >= 1 && parsed.InterleavedPcm.Length > 0) {
+ if (parsed.IsFloat) {
+ if (parsed.BitsPerSample is 32 or 64)
+ foreach (var (name, wavBlob) in PcmCodec.SplitInterleavedFloat(
+ parsed.InterleavedPcm, parsed.NumChannels, parsed.SampleRate, parsed.BitsPerSample, parsed.ChannelMask))
+ entries.Add(new($"{name}.wav", "Channel", wavBlob, "pcm_float"));
+ } else if (parsed.BitsPerSample is 8 or 16 or 24 or 32) {
+ foreach (var (name, wavBlob) in PcmCodec.SplitInterleavedPcm(
+ parsed.InterleavedPcm, parsed.NumChannels, parsed.SampleRate, parsed.BitsPerSample, parsed.ChannelMask))
+ entries.Add(new($"{name}.wav", "Channel", wavBlob, "pcm"));
+ }
}
foreach (var (type, data) in parsed.OtherChunks)
entries.Add(new($"metadata/{type.Trim()}.bin", "Tag", data));
-
return entries;
}
}
diff --git a/FileFormats/FileFormat.Caf/CafReader.cs b/FileFormats/FileFormat.Caf/CafReader.cs
index 6ab0f7be8..0db719331 100644
--- a/FileFormats/FileFormat.Caf/CafReader.cs
+++ b/FileFormats/FileFormat.Caf/CafReader.cs
@@ -1,33 +1,14 @@
#pragma warning disable CS1591
using System.Buffers.Binary;
using System.Text;
+using Codec.ImaAdpcm;
namespace FileFormat.Caf;
///
-/// Apple Core Audio Format (.caf) parser. All multi-byte integers and the
-/// IEEE-754 sample rate are big-endian. Layout:
-///
-/// - File header: ASCII caff | uint16 version (=1) | uint16 flags (=0).
-/// - A sequence of chunks: 4-char ASCII type | int64 size | body[size].
-/// A data chunk may carry size = -1 meaning "to EOF".
-/// - desc chunk (32-byte body): float64 sample rate | 4-char format id
-/// (=lpcm) | uint32 format flags | uint32 bytes-per-packet |
-/// uint32 frames-per-packet | uint32 channels-per-frame | uint32 bits-per-channel.
-/// - data chunk: uint32 edit-count then interleaved PCM bytes.
-///
-/// Format flags: bit 0 (0x1) = IEEE float; bit 1 (0x2) = little-endian samples.
-/// For integer PCM, default (flags = 0) means big-endian samples; this reader converts
-/// such samples to little-endian so downstream callers (and PcmCodec) see a
-/// canonical little-endian buffer.
-/// The G.711 companded formats ulaw and alaw are decoded to 16-bit
-/// little-endian PCM (one source byte per channel sample, channels interleaved bytewise
-/// exactly like LPCM) via Codec.MuLaw/Codec.ALaw; the result reports
-/// = lpcm and
-/// = 16 so the per-channel split path applies. Other compressed formats (ima4,
-/// aac , …) pass through undecoded and are surfaced as FULL.caf only.
-/// Any chunk other than desc/data is kept addressable through
-/// .
+/// Apple Core Audio Format (.caf) parser. All container integers are big-endian;
+/// LPCM sample endianness is controlled by Core Audio's standard ASBD flags.
+/// G.711 and QuickTime IMA4 are decoded to canonical PCM16.
///
public sealed class CafReader {
public sealed record ParsedCaf(
@@ -39,19 +20,23 @@ public sealed record ParsedCaf(
string FormatId,
byte[] InterleavedPcm,
IReadOnlyList<(string Type, byte[] Data)> OtherChunks,
- uint? ChannelMask = null);
+ uint? ChannelMask = null,
+ long? ValidFrames = null);
private const uint FlagIsFloat = 0x1;
- private const uint FlagIsLittleEndian = 0x2;
+ private const uint FlagIsBigEndian = 0x2;
+ private const int Ima4PacketBytesPerChannel = 34;
+ private const int Ima4FramesPerPacket = 64;
public ParsedCaf Read(ReadOnlySpan data) {
if (data.Length < 8)
throw new InvalidDataException("CAF too short for file header.");
- if (data[0] != 'c' || data[1] != 'a' || data[2] != 'f' || data[3] != 'f')
+ if (!data[..4].SequenceEqual("caff"u8))
throw new InvalidDataException("Missing 'caff' magic.");
var pos = 8;
uint? channelMask = null;
+ long? validFrames = null;
var descParsed = false;
int channels = 0, sampleRate = 0, bitsPerChannel = 0;
uint formatFlags = 0;
@@ -66,35 +51,40 @@ public ParsedCaf Read(ReadOnlySpan data) {
long effective;
if (size < 0) {
- // "to EOF" — only valid for the audio data chunk.
+ if (type != "data")
+ throw new InvalidDataException($"CAF chunk '{type}' uses an indefinite size outside the data chunk.");
effective = data.Length - bodyStart;
} else {
effective = size;
- if (bodyStart + effective > data.Length)
- throw new InvalidDataException($"CAF chunk '{type}' truncated.");
+ if (effective > int.MaxValue || bodyStart + effective > data.Length)
+ throw new InvalidDataException($"CAF chunk '{type}' truncated or too large.");
}
- var body = data.Slice(bodyStart, (int)effective);
-
+ var body = data.Slice(bodyStart, checked((int)effective));
switch (type) {
case "desc":
if (body.Length < 32)
throw new InvalidDataException("CAF 'desc' chunk shorter than 32 bytes.");
- sampleRate = (int)BinaryPrimitives.ReadDoubleBigEndian(body);
+ sampleRate = checked((int)BinaryPrimitives.ReadDoubleBigEndian(body));
formatId = Encoding.ASCII.GetString(body.Slice(8, 4));
formatFlags = BinaryPrimitives.ReadUInt32BigEndian(body[12..]);
- channels = (int)BinaryPrimitives.ReadUInt32BigEndian(body[24..]);
- bitsPerChannel = (int)BinaryPrimitives.ReadUInt32BigEndian(body[28..]);
+ channels = checked((int)BinaryPrimitives.ReadUInt32BigEndian(body[24..]));
+ bitsPerChannel = checked((int)BinaryPrimitives.ReadUInt32BigEndian(body[28..]));
descParsed = true;
break;
case "data":
- // First 4 bytes are mEditCount; the rest is the audio payload.
rawData = body.Length >= 4 ? body[4..].ToArray() : [];
break;
+ case "pakt":
+ if (body.Length < 24)
+ throw new InvalidDataException("CAF 'pakt' chunk shorter than 24 bytes.");
+ var packetCount = BinaryPrimitives.ReadInt64BigEndian(body);
+ validFrames = BinaryPrimitives.ReadInt64BigEndian(body[8..]);
+ if (packetCount < 0 || validFrames < 0)
+ throw new InvalidDataException("CAF 'pakt' contains a negative packet or valid-frame count.");
+ other.Add((type, body.ToArray()));
+ break;
case "chan":
- // AudioChannelLayout: mChannelLayoutTag | mChannelBitmap | descriptions.
- // Only the UseChannelBitmap tag (0x10000) carries a WAVE-order speaker
- // mask we can name channels from; other tags stay raw metadata.
if (body.Length >= 8 && BinaryPrimitives.ReadUInt32BigEndian(body) == 0x10000)
channelMask = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
other.Add((type, body.ToArray()));
@@ -104,48 +94,84 @@ public ParsedCaf Read(ReadOnlySpan data) {
break;
}
- pos = bodyStart + (int)effective;
+ pos = checked(bodyStart + (int)effective);
}
if (!descParsed) throw new InvalidDataException("CAF missing 'desc' chunk.");
+ if (channels < 1) throw new InvalidDataException("CAF channel count must be positive.");
+ if (sampleRate < 1) throw new InvalidDataException("CAF sample rate must be positive.");
var isFloat = (formatFlags & FlagIsFloat) != 0;
- var littleEndian = (formatFlags & FlagIsLittleEndian) != 0;
+ var bigEndian = (formatFlags & FlagIsBigEndian) != 0;
var payload = rawData ?? [];
- // G.711 companded formats: decode each byte to a 16-bit linear sample. Bytes are
- // interleaved by channel exactly like LPCM, so the existing channel-split path
- // applies once we expose the decoded 16-bit LE PCM as canonical lpcm.
switch (formatId) {
case "ulaw":
- return new ParsedCaf(channels, sampleRate, BitsPerSample: 16, formatFlags, IsFloat: false,
- FormatId: "lpcm", ShortsToLePcm(Codec.MuLaw.MuLawCodec.Decode(payload)), other, channelMask);
+ return new ParsedCaf(channels, sampleRate, 16, formatFlags, false, "lpcm",
+ ShortsToLePcm(Codec.MuLaw.MuLawCodec.Decode(payload)), other, channelMask, validFrames);
case "alaw":
- return new ParsedCaf(channels, sampleRate, BitsPerSample: 16, formatFlags, IsFloat: false,
- FormatId: "lpcm", ShortsToLePcm(Codec.ALaw.ALawCodec.Decode(payload)), other, channelMask);
+ return new ParsedCaf(channels, sampleRate, 16, formatFlags, false, "lpcm",
+ ShortsToLePcm(Codec.ALaw.ALawCodec.Decode(payload)), other, channelMask, validFrames);
+ case "ima4":
+ return DecodeIma4(channels, sampleRate, formatFlags, payload, other, channelMask, validFrames);
}
- // Convert big-endian integer samples to little-endian so PcmCodec sees canonical PCM.
var canonical = payload;
- if (!isFloat && !littleEndian && bitsPerChannel > 8)
+ if (formatId == "lpcm" && bigEndian && bitsPerChannel > 8)
canonical = ConvertBeToLe(payload, bitsPerChannel / 8);
- return new ParsedCaf(channels, sampleRate, bitsPerChannel, formatFlags, isFloat, formatId, canonical, other, channelMask);
+ return new ParsedCaf(channels, sampleRate, bitsPerChannel, formatFlags, isFloat, formatId,
+ canonical, other, channelMask, validFrames);
+ }
+
+ private static ParsedCaf DecodeIma4(
+ int channels,
+ int sampleRate,
+ uint formatFlags,
+ ReadOnlySpan payload,
+ IReadOnlyList<(string Type, byte[] Data)> other,
+ uint? channelMask,
+ long? validFrames) {
+ var packetBytes = checked(Ima4PacketBytesPerChannel * channels);
+ if (payload.Length % packetBytes != 0)
+ throw new InvalidDataException("CAF ima4 payload does not contain whole interleaved channel packets.");
+
+ var decoded = ImaAdpcmCodec.DecodeQuickTime(payload, channels);
+ var availableFrames = decoded.Length == 0 ? 0 : decoded.Min(static channel => channel.Length);
+ var frameCount = availableFrames;
+ if (validFrames is { } count) {
+ if (count > availableFrames)
+ throw new InvalidDataException($"CAF pakt declares {count} valid frames but ima4 payload contains only {availableFrames}.");
+ frameCount = checked((int)count);
+ }
+
+ var pcm = new byte[checked(frameCount * channels * 2)];
+ var offset = 0;
+ for (var frame = 0; frame < frameCount; ++frame)
+ for (var channel = 0; channel < channels; ++channel) {
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(offset, 2), decoded[channel][frame]);
+ offset += 2;
+ }
+
+ return new ParsedCaf(channels, sampleRate, 16, formatFlags, false, "lpcm",
+ pcm, other, channelMask, validFrames ?? (long)(payload.Length / packetBytes) * Ima4FramesPerPacket);
}
private static byte[] ShortsToLePcm(ReadOnlySpan samples) {
var pcm = new byte[samples.Length * 2];
for (var i = 0; i < samples.Length; ++i)
- BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 2), samples[i]);
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 2, 2), samples[i]);
return pcm;
}
- private static byte[] ConvertBeToLe(byte[] be, int bytesPerSample) {
- if (bytesPerSample <= 1) return (byte[])be.Clone();
- var le = new byte[be.Length];
- for (var i = 0; i + bytesPerSample <= be.Length; i += bytesPerSample)
- for (var j = 0; j < bytesPerSample; ++j)
- le[i + j] = be[i + bytesPerSample - 1 - j];
- return le;
+ private static byte[] ConvertBeToLe(ReadOnlySpan bigEndian, int bytesPerSample) {
+ if (bytesPerSample <= 1) return bigEndian.ToArray();
+ if (bigEndian.Length % bytesPerSample != 0)
+ throw new InvalidDataException("CAF LPCM payload is not aligned to its sample width.");
+ var littleEndian = new byte[bigEndian.Length];
+ for (var offset = 0; offset < bigEndian.Length; offset += bytesPerSample)
+ for (var i = 0; i < bytesPerSample; ++i)
+ littleEndian[offset + i] = bigEndian[offset + bytesPerSample - 1 - i];
+ return littleEndian;
}
}
diff --git a/FileFormats/FileFormat.Caf/FileFormat.Caf.csproj b/FileFormats/FileFormat.Caf/FileFormat.Caf.csproj
index ddf8e318e..e5531f03e 100644
--- a/FileFormats/FileFormat.Caf/FileFormat.Caf.csproj
+++ b/FileFormats/FileFormat.Caf/FileFormat.Caf.csproj
@@ -10,6 +10,7 @@
+
diff --git a/FileFormats/FileFormat.Flac/FileFormat.Flac.csproj b/FileFormats/FileFormat.Flac/FileFormat.Flac.csproj
index ed81acda4..9be3cf328 100644
--- a/FileFormats/FileFormat.Flac/FileFormat.Flac.csproj
+++ b/FileFormats/FileFormat.Flac/FileFormat.Flac.csproj
@@ -4,5 +4,6 @@
+
diff --git a/FileFormats/FileFormat.Flac/FlacFormatDescriptor.cs b/FileFormats/FileFormat.Flac/FlacFormatDescriptor.cs
index cf0348e6f..3b551020b 100644
--- a/FileFormats/FileFormat.Flac/FlacFormatDescriptor.cs
+++ b/FileFormats/FileFormat.Flac/FlacFormatDescriptor.cs
@@ -1,20 +1,21 @@
#pragma warning disable CS1591
+using System.Buffers.Binary;
+using Codec.Flac;
using Codec.Pcm;
using Compression.Registry;
+using FileFormat.Wav;
namespace FileFormat.Flac;
///
-/// Format descriptor and stream operations for the FLAC (Free Lossless Audio Codec) format.
-/// Also surfaces an archive view: FULL.flac plus one mono WAV per channel
-/// (LEFT.wav/RIGHT.wav/...) so multi-channel FLAC files can be
-/// decomposed in the archive browser.
+/// Native FLAC stream descriptor with archive, canonical PCM decode/encode and
+/// channel-WAV creation surfaces.
///
public sealed class FlacFormatDescriptor : IFormatDescriptor, IStreamFormatOperations,
- IArchiveFormatOperations, IArchiveInMemoryExtract, IArchiveLayoutMap {
+ IArchiveFormatOperations, IArchiveInMemoryExtract, IArchiveLayoutMap, IArchiveCreatable,
+ IArchiveWriteConstraints, IAudioContainerFormat, IAudioPcmSource, IAudioPcmTarget {
- ///
public IEnumerable EnumerateLayout(Stream archive) => FlacLayoutMap.Enumerate(archive);
public string Id => "Flac";
@@ -30,14 +31,11 @@ public sealed class FlacFormatDescriptor : IFormatDescriptor, IStreamFormatOpera
public IReadOnlyList Methods => [new("flac", "FLAC")];
public string? TarCompressionFormatId => null;
public AlgorithmFamily Family => AlgorithmFamily.Entropy;
- public string Description => "Free Lossless Audio Codec; full file + decoded per-channel PCM.";
+ public string Description => "Free Lossless Audio Codec; read/write plus decoded per-channel PCM.";
- // ── IStreamFormatOperations ──────────────────────────────────────────
public void Decompress(Stream input, Stream output) => FlacReader.Decompress(input, output);
public void Compress(Stream input, Stream output) => FlacWriter.Compress(input, output);
- // ── IArchiveFormatOperations ─────────────────────────────────────────
-
public List List(Stream stream, string? password) =>
BuildEntries(stream).Select((e, i) => new ArchiveEntryInfo(
Index: i, Name: e.Name,
@@ -48,37 +46,157 @@ public List List(Stream stream, string? password) =>
public void Extract(Stream stream, string outputDir, string? password, string[]? files) {
foreach (var e in BuildEntries(stream)) {
- if (files != null && files.Length > 0 && !FormatHelpers.MatchesFilter(e.Name, files))
+ if (files is { Length: > 0 } && !FormatHelpers.MatchesFilter(e.Name, files))
continue;
FormatHelpers.WriteFile(outputDir, e.Name, e.Data);
}
}
- // ── IArchiveInMemoryExtract ──────────────────────────────────────────
-
public void ExtractEntry(Stream input, string entryName, Stream output, string? password) {
foreach (var e in BuildEntries(input)) {
- if (e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) {
- output.Write(e.Data);
- return;
- }
+ if (!e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) continue;
+ output.Write(e.Data);
+ return;
}
throw new FileNotFoundException($"Entry not found: {entryName}");
}
- // ── Shared archive-entry builder ─────────────────────────────────────
+ public long? MaxTotalArchiveSize => null;
+ public string AcceptedInputsDescription =>
+ "FLAC accepts FULL.flac or 1-8 mono integer-PCM WAV channels with matching geometry.";
+
+ public bool CanAccept(ArchiveInputInfo input, out string? reason) {
+ var name = Path.GetFileName(input.ArchiveName);
+ if (name.Equals("FULL.flac", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) {
+ reason = null;
+ return true;
+ }
+ reason = $"not a FLAC input (got {input.ArchiveName}); {AcceptedInputsDescription}";
+ return false;
+ }
+
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) {
+ var files = FormatHelpers.FilesOnly(inputs).ToList();
+ var full = files.FirstOrDefault(static file =>
+ Path.GetFileName(file.Name).Equals("FULL.flac", StringComparison.OrdinalIgnoreCase));
+ if (full.Data is not null) {
+ output.Write(full.Data);
+ return;
+ }
+
+ var channels = files
+ .Where(static file => file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static file => ChannelLayout.OrderIndex(Path.GetFileNameWithoutExtension(file.Name)))
+ .Select(static file => new WavReader().Read(file.Data))
+ .ToArray();
+ if (channels.Length is < 1 or > 8)
+ throw new InvalidOperationException("FLAC creation requires 1-8 mono WAV channel inputs.");
+
+ var first = channels[0];
+ if (first.NumChannels != 1 || first.FormatCode != 1 || first.BitsPerSample is not (8 or 16 or 24 or 32))
+ throw new InvalidOperationException("FLAC creation requires integer PCM WAV input at 8/16/24/32 bits.");
+ if (channels.Any(channel => channel.NumChannels != 1 || channel.FormatCode != 1 ||
+ channel.BitsPerSample != first.BitsPerSample || channel.SampleRate != first.SampleRate ||
+ channel.InterleavedPcm.Length != first.InterleavedPcm.Length))
+ throw new InvalidOperationException("All FLAC channel WAVs must have matching PCM geometry and frame count.");
+
+ var interleaved = PcmCodec.Interleave(channels.Select(static channel => channel.InterleavedPcm).ToList(), first.BitsPerSample);
+ var encoding = first.BitsPerSample == 8 ? AudioPcmEncoding.UnsignedInteger : AudioPcmEncoding.SignedInteger;
+ this.EncodePcm(output,
+ new AudioPcmBuffer(new AudioPcmFormat(first.SampleRate, channels.Length, first.BitsPerSample, encoding), interleaved),
+ "flac", options);
+ }
+
+ public IReadOnlyList SupportedEncodeCodecs => ["flac"];
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!codecId.Equals("flac", StringComparison.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not FLAC";
+ return false;
+ }
+ if (format.Channels is < 1 or > 8 || format.SampleRate is < 1 or > 1_048_575) {
+ reason = "FLAC requires 1-8 channels and a positive 20-bit sample rate";
+ return false;
+ }
+ if (format.BitsPerSample is not (8 or 16 or 24 or 32) || format.Encoding == AudioPcmEncoding.IeeeFloat) {
+ reason = "FLAC target currently accepts 8/16/24/32-bit integer PCM";
+ return false;
+ }
+ if (format.BitsPerSample != 8 && format.Encoding != AudioPcmEncoding.SignedInteger) {
+ reason = "multi-byte FLAC PCM must be signed integer";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+ var samples = DecodeIntegerPcm(pcm);
+ var blockSize = options.TryGetInt("block-size", out var configuredBlockSize) ? configuredBlockSize : 4096;
+ var compression = options.GetString("subframe")?.ToLowerInvariant() switch {
+ "verbatim" => FlacSubframeMode.Verbatim,
+ "fixed0" => FlacSubframeMode.Fixed0,
+ "fixed1" => FlacSubframeMode.Fixed1,
+ "fixed2" => FlacSubframeMode.Fixed2,
+ "fixed3" => FlacSubframeMode.Fixed3,
+ "fixed4" => FlacSubframeMode.Fixed4,
+ _ => FlacSubframeMode.Auto,
+ };
+ var stereo = options.GetString("stereo-mode")?.ToLowerInvariant() switch {
+ "independent" => FlacStereoMode.Independent,
+ "left-side" => FlacStereoMode.LeftSide,
+ "right-side" => FlacStereoMode.RightSide,
+ "mid-side" or "midside" or "ms" => FlacStereoMode.MidSide,
+ _ => FlacStereoMode.Auto,
+ };
+ output.Write(FlacCodec.Encode(samples, new FlacEncoderOptions(
+ pcm.Format.SampleRate, pcm.Format.Channels, pcm.Format.BitsPerSample, blockSize, compression, stereo)));
+ }
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ var data = ReadAll(input);
+ var props = FlacReader.ReadAudioProperties(data);
+ using var source = new MemoryStream(data, writable: false);
+ using var pcm = new MemoryStream();
+ FlacReader.Decompress(source, pcm);
+ return new AudioPcmBuffer(
+ new AudioPcmFormat(props.SampleRate, props.Channels, props.BitsPerSample, AudioPcmEncoding.SignedInteger),
+ pcm.ToArray());
+ }
+
+ private static int[] DecodeIntegerPcm(AudioPcmBuffer pcm) {
+ var bytesPerSample = pcm.Format.BytesPerSample;
+ if (pcm.InterleavedData.Length % bytesPerSample != 0)
+ throw new InvalidDataException("PCM byte count is not aligned to its sample width.");
+ var samples = new int[pcm.InterleavedData.Length / bytesPerSample];
+ for (var i = 0; i < samples.Length; ++i) {
+ var span = pcm.InterleavedData.AsSpan(i * bytesPerSample, bytesPerSample);
+ samples[i] = pcm.Format.BitsPerSample switch {
+ 8 => pcm.Format.Encoding == AudioPcmEncoding.UnsignedInteger ? span[0] - 128 : (sbyte)span[0],
+ 16 => BinaryPrimitives.ReadInt16LittleEndian(span),
+ 24 => SignExtend24(span),
+ 32 => BinaryPrimitives.ReadInt32LittleEndian(span),
+ _ => throw new NotSupportedException($"Unsupported FLAC PCM width {pcm.Format.BitsPerSample}."),
+ };
+ }
+ return samples;
+ }
+
+ private static int SignExtend24(ReadOnlySpan bytes) {
+ var value = bytes[0] | bytes[1] << 8 | bytes[2] << 16;
+ return (value & 0x0080_0000) != 0 ? value | unchecked((int)0xFF00_0000) : value;
+ }
private static IReadOnlyList<(string Name, string Kind, byte[] Data)> BuildEntries(Stream stream) {
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var blob = ms.ToArray();
+ var blob = ReadAll(stream);
var entries = new List<(string, string, byte[])> {
("FULL.flac", "Container", blob),
};
var props = FlacReader.ReadAudioProperties(blob);
-
- // Decode to interleaved PCM, then split per-channel.
using var src = new MemoryStream(blob);
using var pcm = new MemoryStream();
FlacReader.Decompress(src, pcm);
@@ -94,4 +212,11 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string?
}
return entries;
}
+
+ private static byte[] ReadAll(Stream input) {
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ return memory.ToArray();
+ }
}
diff --git a/FileFormats/FileFormat.Mp4/Mp4AudioMuxer.cs b/FileFormats/FileFormat.Mp4/Mp4AudioMuxer.cs
new file mode 100644
index 000000000..115894df4
--- /dev/null
+++ b/FileFormats/FileFormat.Mp4/Mp4AudioMuxer.cs
@@ -0,0 +1,359 @@
+#pragma warning disable CS1591
+using System.Buffers.Binary;
+using System.Text;
+using Compression.Registry;
+
+namespace FileFormat.Mp4;
+
+/// Minimal standards-based audio-only ISO BMFF writer for AAC access units.
+internal static class Mp4AudioMuxer {
+
+ internal static byte[] MuxAac(AudioEncodedStream stream) {
+ if (!stream.Format.CodecId.Equals("aac", StringComparison.OrdinalIgnoreCase))
+ throw new NotSupportedException($"MP4 AAC muxer cannot carry codec '{stream.Format.CodecId}'.");
+ if (stream.Format.SampleRate <= 0 || stream.Format.Channels is < 1 or > 2)
+ throw new ArgumentOutOfRangeException(nameof(stream), "AAC MP4 muxing requires a positive sample rate and mono/stereo channels.");
+ if (stream.Packets.Count == 0)
+ throw new ArgumentException("AAC MP4 muxing requires at least one access unit.", nameof(stream));
+ if (stream.CodecPrivateData is not { Length: >= 2 } asc)
+ throw new ArgumentException("AAC MP4 muxing requires AudioSpecificConfig codec-private data.", nameof(stream));
+
+ var sampleDurations = stream.Packets
+ .Select(static packet => checked((uint)(packet.DurationSamples > 0 ? packet.DurationSamples : 1024)))
+ .ToArray();
+ var mediaDuration = sampleDurations.Aggregate(0UL, static (sum, value) => sum + value);
+ var mediaBytes = checked((int)stream.Packets.Sum(static packet => (long)packet.Data.Length));
+ var averageBitrate = mediaDuration == 0
+ ? 0u
+ : checked((uint)Math.Min(uint.MaxValue,
+ (ulong)mediaBytes * 8UL * (ulong)stream.Format.SampleRate / mediaDuration));
+
+ var ftyp = BuildFtyp();
+ var mdatPayload = new byte[mediaBytes];
+ var mediaOffset = 0;
+ foreach (var packet in stream.Packets) {
+ packet.Data.CopyTo(mdatPayload, mediaOffset);
+ mediaOffset += packet.Data.Length;
+ }
+ var mdat = Box("mdat", mdatPayload);
+ var chunkOffset = checked((uint)(ftyp.Length + 8));
+ var moov = BuildMoov(stream, asc, sampleDurations, mediaDuration, averageBitrate, chunkOffset);
+
+ var result = new byte[checked(ftyp.Length + mdat.Length + moov.Length)];
+ ftyp.CopyTo(result, 0);
+ mdat.CopyTo(result, ftyp.Length);
+ moov.CopyTo(result, ftyp.Length + mdat.Length);
+ return result;
+ }
+
+ private static byte[] BuildFtyp() {
+ using var body = new MemoryStream();
+ body.Write("M4A "u8);
+ WriteUInt32(body, 0x0000_0200);
+ body.Write("M4A "u8);
+ body.Write("isom"u8);
+ body.Write("mp42"u8);
+ return Box("ftyp", body.ToArray());
+ }
+
+ private static byte[] BuildMoov(
+ AudioEncodedStream stream,
+ byte[] asc,
+ uint[] durations,
+ ulong mediaDuration,
+ uint averageBitrate,
+ uint chunkOffset
+ ) {
+ var movieTimescale = 1_000u;
+ var movieDuration = checked((uint)Math.Min(uint.MaxValue,
+ mediaDuration * movieTimescale / (ulong)stream.Format.SampleRate));
+ var mvhd = BuildMvhd(movieTimescale, movieDuration);
+ var trak = BuildTrak(stream, asc, durations, mediaDuration, averageBitrate, chunkOffset, movieTimescale, movieDuration);
+ return Container("moov", mvhd, trak);
+ }
+
+ private static byte[] BuildMvhd(uint timescale, uint duration) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0); // version + flags
+ WriteUInt32(body, 0); // creation
+ WriteUInt32(body, 0); // modification
+ WriteUInt32(body, timescale);
+ WriteUInt32(body, duration);
+ WriteUInt32(body, 0x0001_0000); // rate 1.0
+ WriteUInt16(body, 0x0100); // volume 1.0
+ body.Write(new byte[10]);
+ WriteUnityMatrix(body);
+ body.Write(new byte[24]);
+ WriteUInt32(body, 2); // next track id
+ return Box("mvhd", body.ToArray());
+ }
+
+ private static byte[] BuildTrak(
+ AudioEncodedStream stream,
+ byte[] asc,
+ uint[] durations,
+ ulong mediaDuration,
+ uint averageBitrate,
+ uint chunkOffset,
+ uint movieTimescale,
+ uint movieDuration
+ ) {
+ var tkhd = BuildTkhd(movieDuration);
+ var mdia = BuildMdia(stream, asc, durations, mediaDuration, averageBitrate, chunkOffset);
+ return Container("trak", tkhd, mdia);
+ }
+
+ private static byte[] BuildTkhd(uint movieDuration) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0x0000_0007); // enabled + in movie + in preview
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 1); // track id
+ WriteUInt32(body, 0);
+ WriteUInt32(body, movieDuration);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ WriteUInt16(body, 0); // layer
+ WriteUInt16(body, 0); // alternate group
+ WriteUInt16(body, 0x0100); // audio volume
+ WriteUInt16(body, 0);
+ WriteUnityMatrix(body);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ return Box("tkhd", body.ToArray());
+ }
+
+ private static byte[] BuildMdia(
+ AudioEncodedStream stream,
+ byte[] asc,
+ uint[] durations,
+ ulong mediaDuration,
+ uint averageBitrate,
+ uint chunkOffset
+ ) {
+ var mdhd = BuildMdhd((uint)stream.Format.SampleRate, checked((uint)Math.Min(uint.MaxValue, mediaDuration)));
+ var hdlr = BuildHdlr();
+ var minf = BuildMinf(stream, asc, durations, averageBitrate, chunkOffset);
+ return Container("mdia", mdhd, hdlr, minf);
+ }
+
+ private static byte[] BuildMdhd(uint timescale, uint duration) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, timescale);
+ WriteUInt32(body, duration);
+ WriteUInt16(body, 0x55C4); // und
+ WriteUInt16(body, 0);
+ return Box("mdhd", body.ToArray());
+ }
+
+ private static byte[] BuildHdlr() {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ body.Write("soun"u8);
+ body.Write(new byte[12]);
+ body.Write("SoundHandler\0"u8);
+ return Box("hdlr", body.ToArray());
+ }
+
+ private static byte[] BuildMinf(
+ AudioEncodedStream stream,
+ byte[] asc,
+ uint[] durations,
+ uint averageBitrate,
+ uint chunkOffset
+ ) {
+ var smhd = FullBox("smhd", 0, [0, 0, 0, 0]);
+ var dinf = BuildDinf();
+ var stbl = BuildStbl(stream, asc, durations, averageBitrate, chunkOffset);
+ return Container("minf", smhd, dinf, stbl);
+ }
+
+ private static byte[] BuildDinf() {
+ var url = FullBox("url ", 1, []); // self-contained media data
+ using var drefBody = new MemoryStream();
+ WriteUInt32(drefBody, 0);
+ WriteUInt32(drefBody, 1);
+ drefBody.Write(url);
+ return Container("dinf", Box("dref", drefBody.ToArray()));
+ }
+
+ private static byte[] BuildStbl(
+ AudioEncodedStream stream,
+ byte[] asc,
+ uint[] durations,
+ uint averageBitrate,
+ uint chunkOffset
+ ) {
+ var stsd = BuildStsd(stream.Format, asc, averageBitrate);
+ var stts = BuildStts(durations);
+ var stsc = BuildStsc(stream.Packets.Count);
+ var stsz = BuildStsz(stream.Packets);
+ var stco = BuildStco(chunkOffset);
+ return Container("stbl", stsd, stts, stsc, stsz, stco);
+ }
+
+ private static byte[] BuildStsd(AudioStreamFormat format, byte[] asc, uint averageBitrate) {
+ var esds = BuildEsds(asc, averageBitrate);
+ using var entry = new MemoryStream();
+ entry.Write(new byte[6]);
+ WriteUInt16(entry, 1); // data reference index
+ WriteUInt16(entry, 0); // version
+ WriteUInt16(entry, 0); // revision level
+ WriteUInt32(entry, 0); // vendor
+ WriteUInt16(entry, checked((ushort)format.Channels));
+ WriteUInt16(entry, 16);
+ WriteUInt16(entry, 0); // compression id
+ WriteUInt16(entry, 0); // packet size
+ WriteUInt32(entry, checked((uint)format.SampleRate << 16));
+ entry.Write(esds);
+ var mp4a = Box("mp4a", entry.ToArray());
+
+ using var stsdBody = new MemoryStream();
+ WriteUInt32(stsdBody, 0);
+ WriteUInt32(stsdBody, 1);
+ stsdBody.Write(mp4a);
+ return Box("stsd", stsdBody.ToArray());
+ }
+
+ private static byte[] BuildEsds(byte[] asc, uint averageBitrate) {
+ var decoderSpecific = Descriptor(0x05, asc);
+ using var decoderConfigBody = new MemoryStream();
+ decoderConfigBody.WriteByte(0x40); // MPEG-4 Audio
+ decoderConfigBody.WriteByte(0x15); // AudioStream, upstream=0, reserved=1
+ decoderConfigBody.Write([0, 0, 0]); // bufferSizeDB
+ WriteUInt32(decoderConfigBody, averageBitrate);
+ WriteUInt32(decoderConfigBody, averageBitrate);
+ decoderConfigBody.Write(decoderSpecific);
+ var decoderConfig = Descriptor(0x04, decoderConfigBody.ToArray());
+ var slConfig = Descriptor(0x06, [0x02]);
+
+ using var esBody = new MemoryStream();
+ WriteUInt16(esBody, 1); // ES_ID
+ esBody.WriteByte(0); // flags
+ esBody.Write(decoderConfig);
+ esBody.Write(slConfig);
+ var esDescriptor = Descriptor(0x03, esBody.ToArray());
+
+ using var full = new MemoryStream();
+ WriteUInt32(full, 0);
+ full.Write(esDescriptor);
+ return Box("esds", full.ToArray());
+ }
+
+ private static byte[] BuildStts(uint[] durations) {
+ var runs = new List<(uint Count, uint Duration)>();
+ foreach (var duration in durations) {
+ if (runs.Count > 0 && runs[^1].Duration == duration) {
+ var last = runs[^1];
+ runs[^1] = (last.Count + 1, last.Duration);
+ } else {
+ runs.Add((1, duration));
+ }
+ }
+
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, checked((uint)runs.Count));
+ foreach (var run in runs) {
+ WriteUInt32(body, run.Count);
+ WriteUInt32(body, run.Duration);
+ }
+ return Box("stts", body.ToArray());
+ }
+
+ private static byte[] BuildStsc(int sampleCount) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 1);
+ WriteUInt32(body, 1);
+ WriteUInt32(body, checked((uint)sampleCount));
+ WriteUInt32(body, 1);
+ return Box("stsc", body.ToArray());
+ }
+
+ private static byte[] BuildStsz(IReadOnlyList packets) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 0);
+ WriteUInt32(body, checked((uint)packets.Count));
+ foreach (var packet in packets)
+ WriteUInt32(body, checked((uint)packet.Data.Length));
+ return Box("stsz", body.ToArray());
+ }
+
+ private static byte[] BuildStco(uint chunkOffset) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, 0);
+ WriteUInt32(body, 1);
+ WriteUInt32(body, chunkOffset);
+ return Box("stco", body.ToArray());
+ }
+
+ private static byte[] Descriptor(byte tag, byte[] body) {
+ using var stream = new MemoryStream();
+ stream.WriteByte(tag);
+ WriteDescriptorLength(stream, body.Length);
+ stream.Write(body);
+ return stream.ToArray();
+ }
+
+ private static void WriteDescriptorLength(Stream output, int length) {
+ Span encoded = stackalloc byte[4];
+ var count = 0;
+ do {
+ encoded[count++] = (byte)(length & 0x7F);
+ length >>= 7;
+ } while (length != 0);
+ for (var i = count - 1; i >= 0; --i)
+ output.WriteByte((byte)(encoded[i] | (i == 0 ? 0 : 0x80)));
+ }
+
+ private static byte[] Container(string type, params byte[][] children) {
+ var length = children.Sum(static child => child.Length);
+ var payload = new byte[length];
+ var offset = 0;
+ foreach (var child in children) {
+ child.CopyTo(payload, offset);
+ offset += child.Length;
+ }
+ return Box(type, payload);
+ }
+
+ private static byte[] FullBox(string type, uint versionAndFlags, byte[] payload) {
+ using var body = new MemoryStream();
+ WriteUInt32(body, versionAndFlags);
+ body.Write(payload);
+ return Box(type, body.ToArray());
+ }
+
+ private static byte[] Box(string type, byte[] payload) {
+ if (type.Length != 4) throw new ArgumentException("ISO BMFF box types are four characters.", nameof(type));
+ var result = new byte[checked(payload.Length + 8)];
+ BinaryPrimitives.WriteUInt32BigEndian(result, checked((uint)result.Length));
+ Encoding.ASCII.GetBytes(type, result.AsSpan(4, 4));
+ payload.CopyTo(result, 8);
+ return result;
+ }
+
+ private static void WriteUnityMatrix(Stream output) {
+ WriteUInt32(output, 0x0001_0000); WriteUInt32(output, 0); WriteUInt32(output, 0);
+ WriteUInt32(output, 0); WriteUInt32(output, 0x0001_0000); WriteUInt32(output, 0);
+ WriteUInt32(output, 0); WriteUInt32(output, 0); WriteUInt32(output, 0x4000_0000);
+ }
+
+ private static void WriteUInt16(Stream output, ushort value) {
+ Span bytes = stackalloc byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(bytes, value);
+ output.Write(bytes);
+ }
+
+ private static void WriteUInt32(Stream output, uint value) {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
+ output.Write(bytes);
+ }
+}
diff --git a/FileFormats/FileFormat.Mp4/Mp4FormatDescriptor.cs b/FileFormats/FileFormat.Mp4/Mp4FormatDescriptor.cs
index 527b35848..365243a16 100644
--- a/FileFormats/FileFormat.Mp4/Mp4FormatDescriptor.cs
+++ b/FileFormats/FileFormat.Mp4/Mp4FormatDescriptor.cs
@@ -1,21 +1,26 @@
#pragma warning disable CS1591
+using System.Buffers.Binary;
using System.Text;
+using Codec.Aac;
using Compression.Registry;
namespace FileFormat.Mp4;
///
-/// Exposes an MP4/MOV file as an archive of demuxed tracks. Video tracks produce
-/// raw H.264 Annex-B (or raw sample data for non-H.264 codecs); audio tracks
-/// produce the concatenated sample payload in track order. Not a re-muxer — the
-/// output is elementary streams, not playable MP4 fragments.
+/// MP4/MOV demux surface plus an audio-only M4A write path. The writer currently
+/// accepts AAC-LC access units directly or canonical PCM16 that can be encoded to AAC-LC.
///
-public sealed class Mp4FormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveInMemoryExtract, IFileInternalLayoutMap, IFileInternalChunkMover {
+public sealed class Mp4FormatDescriptor : IFormatDescriptor, IArchiveFormatOperations,
+ IArchiveInMemoryExtract, IFileInternalLayoutMap, IFileInternalChunkMover,
+ IAudioContainerFormat, IAudioMuxTarget, IAudioPcmTarget {
+
+ private static readonly string[] AacCodecs = ["aac", "aac-lc"];
+
public string Id => "Mp4";
public string DisplayName => "MP4 / MOV (demuxed)";
public FormatCategory Category => FormatCategory.Video;
public FormatCapabilities Capabilities =>
- FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest |
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanTest |
FormatCapabilities.SupportsMultipleEntries;
public string DefaultExtension => ".mp4";
public IReadOnlyList Extensions => [".mp4", ".m4v", ".m4a", ".mov", ".3gp", ".3g2"];
@@ -23,10 +28,10 @@ public sealed class Mp4FormatDescriptor : IFormatDescriptor, IArchiveFormatOpera
public IReadOnlyList MagicSignatures => [
new("ftyp"u8.ToArray(), Offset: 4, Confidence: 0.9),
];
- public IReadOnlyList Methods => [new("stored", "Stored")];
+ public IReadOnlyList Methods => [new("aac", "AAC-LC audio / M4A")];
public string? TarCompressionFormatId => null;
public AlgorithmFamily Family => AlgorithmFamily.Archive;
- public string Description => "MP4/MOV container; each track extractable as an elementary stream.";
+ public string Description => "MP4/MOV container; demuxed tracks plus audio-only AAC-LC M4A muxing.";
public List List(Stream stream, string? password) =>
BuildEntries(stream).Select((e, i) => new ArchiveEntryInfo(
@@ -37,7 +42,7 @@ public List List(Stream stream, string? password) =>
public void Extract(Stream stream, string outputDir, string? password, string[]? files) {
foreach (var e in BuildEntries(stream)) {
- if (files != null && files.Length > 0 && !FormatHelpers.MatchesFilter(e.Name, files))
+ if (files is { Length: > 0 } && !FormatHelpers.MatchesFilter(e.Name, files))
continue;
FormatHelpers.WriteFile(outputDir, e.Name, e.Data);
}
@@ -45,14 +50,115 @@ public void Extract(Stream stream, string outputDir, string? password, string[]?
public void ExtractEntry(Stream input, string entryName, Stream output, string? password) {
foreach (var e in BuildEntries(input)) {
- if (e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) {
- output.Write(e.Data);
- return;
- }
+ if (!e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) continue;
+ output.Write(e.Data);
+ return;
}
throw new FileNotFoundException($"Entry not found: {entryName}");
}
+ public IReadOnlyList SupportedMuxCodecs => ["aac"];
+
+ public bool CanMux(AudioStreamFormat stream, FormatCreateOptions options, out string? reason) {
+ if (!stream.CodecId.Equals("aac", StringComparison.OrdinalIgnoreCase)) {
+ reason = "the audio-only MP4 writer currently accepts AAC access units";
+ return false;
+ }
+ if (stream.SampleRate <= 0 || stream.Channels is < 1 or > 2) {
+ reason = "AAC M4A muxing requires mono/stereo with a positive sample rate";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void Mux(Stream output, AudioEncodedStream stream, FormatCreateOptions options) {
+ ArgumentNullException.ThrowIfNull(output);
+ ArgumentNullException.ThrowIfNull(stream);
+ ArgumentNullException.ThrowIfNull(options);
+ if (!this.CanMux(stream.Format, options, out var reason))
+ throw new NotSupportedException(reason);
+ output.Write(Mp4AudioMuxer.MuxAac(stream));
+ }
+
+ public IReadOnlyList SupportedEncodeCodecs => AacCodecs;
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!AacCodecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"codec '{codecId}' is not supported by the M4A writer";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "M4A AAC encoding requires signed PCM16 input";
+ return false;
+ }
+ if (format.Channels is < 1 or > 2) {
+ reason = "the current AAC-LC encoder supports mono or stereo";
+ return false;
+ }
+ if (Array.IndexOf(AacAdtsReader.SampleRateTable, format.SampleRate) is < 0 or > 12) {
+ reason = "sample rate is not an AAC/ADTS standard rate";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+
+ var samples = new short[pcm.InterleavedData.Length / 2];
+ for (var i = 0; i < samples.Length; ++i)
+ samples[i] = BinaryPrimitives.ReadInt16LittleEndian(pcm.InterleavedData.AsSpan(i * 2, 2));
+
+ var bitrate = options.TryGetInt("bitrate", out var configuredBitrate)
+ ? configuredBitrate
+ : pcm.Format.Channels == 1 ? 64_000 : 128_000;
+ var cutoff = options.TryGetInt("cutoff", out var configuredCutoff) ? configuredCutoff : 0;
+ var window = options.GetString("window")?.ToLowerInvariant() switch {
+ "kbd" => AacEncoderWindowShape.Kbd,
+ _ => AacEncoderWindowShape.Sine,
+ };
+ var stereoMode = options.GetString("stereo-mode")?.ToLowerInvariant() switch {
+ "independent" => AacStereoCodingMode.Independent,
+ "ms" or "mid-side" or "midside" => AacStereoCodingMode.MidSide,
+ _ => AacStereoCodingMode.Auto,
+ };
+
+ var adts = AacEncoder.Encode(samples, new AacEncoderOptions(
+ pcm.Format.SampleRate, pcm.Format.Channels, bitrate, cutoff, window, stereoMode));
+ var encoded = DemuxAdts(adts);
+ this.Mux(output, encoded, options);
+ }
+
+ private static AudioEncodedStream DemuxAdts(byte[] adts) {
+ var packets = new List();
+ AdtsHeader? first = null;
+ var offset = 0;
+ while (offset + AacAdtsReader.ShortHeaderLength <= adts.Length) {
+ var header = AacAdtsReader.ParseHeader(adts, offset);
+ if (header.FrameLength < header.HeaderLengthBytes || offset + header.FrameLength > adts.Length)
+ throw new InvalidDataException("Encoded ADTS frame overruns buffer.");
+ first ??= header;
+ packets.Add(new AudioPacket(
+ adts.AsSpan(offset + header.HeaderLengthBytes, header.FrameLength - header.HeaderLengthBytes).ToArray(),
+ (header.NumberOfRawDataBlocks + 1L) * AacEncoder.FrameSamples));
+ offset += header.FrameLength;
+ }
+ if (first is not { } initial || packets.Count == 0 || offset != adts.Length)
+ throw new InvalidDataException("AAC encoder produced an incomplete ADTS stream.");
+
+ var objectType = initial.Profile + 1;
+ var asc = new byte[2];
+ asc[0] = (byte)((objectType << 3) | (initial.SampleRateIndex >> 1));
+ asc[1] = (byte)(((initial.SampleRateIndex & 1) << 7) | (initial.ChannelConfiguration << 3));
+ return new AudioEncodedStream(
+ new AudioStreamFormat("aac", initial.SampleRate, initial.ChannelConfiguration),
+ packets,
+ asc);
+ }
+
/// Maximum number of individual frame entries per video track.
private const int MaxFrameEntries = 100_000;
@@ -69,7 +175,6 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string?
var name = $"track_{t.Id:D2}_{t.HandlerType}_{t.CodecFourCc}{ext}";
entries.Add((name, "Track", t.Data));
- // Emit individual video frames.
if (t.HandlerType == "vide" && t.Samples.Count > 0) {
var frameExt = ChooseFrameExtension(t.CodecFourCc);
var frameCount = Math.Min(t.Samples.Count, MaxFrameEntries);
@@ -78,9 +183,6 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string?
}
}
- // Best-effort per-audio-track decode → one mono WAV per speaker (Kind Channel).
- // Audio traks keep their raw concatenated-sample entry above; here we add the
- // decoded channels plus a metadata.ini note. Failures fall back to raw-only.
var audioTracks = Mp4AudioChannels.Decode(file);
if (audioTracks.Count > 0) {
var meta = new StringBuilder();
@@ -98,10 +200,8 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string?
}
private static string ChooseExtension(string handlerType, string codec) => (handlerType, codec) switch {
- ("vide", "avc1") => ".h264",
- ("vide", "avc3") => ".h264",
- ("vide", "hvc1") => ".hevc",
- ("vide", "hev1") => ".hevc",
+ ("vide", "avc1") or ("vide", "avc3") => ".h264",
+ ("vide", "hvc1") or ("vide", "hev1") => ".hevc",
("vide", "mp4v") => ".m4v",
("vide", "mjpa") or ("vide", "mjpb") => ".mjpg",
("vide", _) => ".bin",
@@ -112,7 +212,6 @@ public void ExtractEntry(Stream input, string entryName, Stream output, string?
_ => ".bin",
};
- /// Returns the appropriate extension for an individual video frame.
private static string ChooseFrameExtension(string codec) => codec switch {
"avc1" or "avc3" => ".h264",
"hvc1" or "hev1" => ".hevc",
diff --git a/FileFormats/FileFormat.Ogg/FileFormat.Ogg.csproj b/FileFormats/FileFormat.Ogg/FileFormat.Ogg.csproj
index 0aeeab3c7..fd99b38b7 100644
--- a/FileFormats/FileFormat.Ogg/FileFormat.Ogg.csproj
+++ b/FileFormats/FileFormat.Ogg/FileFormat.Ogg.csproj
@@ -10,6 +10,7 @@
+
diff --git a/FileFormats/FileFormat.Ogg/OggFormatDescriptor.cs b/FileFormats/FileFormat.Ogg/OggFormatDescriptor.cs
index 8224bb882..60daf3f63 100644
--- a/FileFormats/FileFormat.Ogg/OggFormatDescriptor.cs
+++ b/FileFormats/FileFormat.Ogg/OggFormatDescriptor.cs
@@ -1,37 +1,42 @@
#pragma warning disable CS1591
+using System.Buffers.Binary;
+using System.Globalization;
using System.Text;
using Codec.Opus;
using Codec.Pcm;
using Codec.Speex;
using Codec.Vorbis;
using Compression.Registry;
+using FileFormat.Wav;
namespace FileFormat.Ogg;
///
-/// Surfaces an OGG container as an archive of the full file + per-logical-stream
-/// raw packet blobs + the Vorbis/Opus comment block. When the primary audio stream
-/// decodes (Vorbis via Codec.Vorbis, Opus via Codec.Opus) the archive
-/// also surfaces one mono <CHANNEL>.wav per channel; streams the decoder
-/// can't handle fall back to the raw packet blobs only.
+/// Ogg container with packet inspection, PCM decode, and managed Vorbis/Opus creation.
///
-public sealed class OggFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveInMemoryExtract, IFileInternalLayoutMap {
+public sealed class OggFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations,
+ IArchiveInMemoryExtract, IFileInternalLayoutMap, IArchiveCreatable, IArchiveWriteConstraints,
+ IAudioContainerFormat, IAudioPcmSource, IAudioPcmTarget {
+
+ private static readonly string[] EncodeCodecs = ["vorbis", "opus"];
+
public string Id => "Ogg";
public string DisplayName => "OGG (Xiph container)";
public FormatCategory Category => FormatCategory.Audio;
public FormatCapabilities Capabilities =>
- FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest |
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanCreate | FormatCapabilities.CanTest |
FormatCapabilities.SupportsMultipleEntries;
public string DefaultExtension => ".ogg";
public IReadOnlyList Extensions => [".ogg", ".oga", ".opus", ".spx"];
public IReadOnlyList CompoundExtensions => [];
- public IReadOnlyList MagicSignatures => [
- new("OggS"u8.ToArray(), Confidence: 0.95),
+ public IReadOnlyList MagicSignatures => [new("OggS"u8.ToArray(), Confidence: 0.95)];
+ public IReadOnlyList Methods => [
+ new("vorbis", "Ogg Vorbis"),
+ new("opus", "Ogg Opus"),
];
- public IReadOnlyList Methods => [new("stored", "Stored")];
public string? TarCompressionFormatId => null;
public AlgorithmFamily Family => AlgorithmFamily.Archive;
- public string Description => "Ogg bitstream; per-stream packets + Vorbis/Opus comments.";
+ public string Description => "Ogg bitstream; packet inspection plus Vorbis/Opus read/write and per-channel PCM.";
public List List(Stream stream, string? password) =>
BuildEntries(stream).Select((e, i) => new ArchiveEntryInfo(
@@ -43,132 +48,244 @@ public List List(Stream stream, string? password) =>
public void Extract(Stream stream, string outputDir, string? password, string[]? files) {
foreach (var e in BuildEntries(stream)) {
- if (files != null && files.Length > 0 && !FormatHelpers.MatchesFilter(e.Name, files))
- continue;
+ if (files is { Length: > 0 } && !FormatHelpers.MatchesFilter(e.Name, files)) continue;
FormatHelpers.WriteFile(outputDir, e.Name, e.Data);
}
}
public void ExtractEntry(Stream input, string entryName, Stream output, string? password) {
foreach (var e in BuildEntries(input)) {
- if (e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) {
- output.Write(e.Data);
- return;
- }
+ if (!e.Name.Equals(entryName, StringComparison.OrdinalIgnoreCase)) continue;
+ output.Write(e.Data);
+ return;
}
throw new FileNotFoundException($"Entry not found: {entryName}");
}
- ///
public IEnumerable EnumerateChunks(Stream file) => OggLayoutMap.Enumerate(file);
- private static IReadOnlyList<(string Name, string Kind, byte[] Data)> BuildEntries(Stream stream) {
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var blob = ms.ToArray();
+ public long? MaxTotalArchiveSize => null;
+ public string AcceptedInputsDescription =>
+ "Ogg accepts FULL.ogg or 1-8 mono PCM16 WAV channels; method/codec selects vorbis or opus.";
+
+ public bool CanAccept(ArchiveInputInfo input, out string? reason) {
+ var name = Path.GetFileName(input.ArchiveName);
+ if (name.Equals("FULL.ogg", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) {
+ reason = null;
+ return true;
+ }
+ reason = $"not an Ogg input (got {input.ArchiveName}); {AcceptedInputsDescription}";
+ return false;
+ }
+
+ public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) {
+ var files = FormatHelpers.FilesOnly(inputs).ToList();
+ var full = files.FirstOrDefault(static file =>
+ Path.GetFileName(file.Name).Equals("FULL.ogg", StringComparison.OrdinalIgnoreCase));
+ if (full.Data is not null) {
+ output.Write(full.Data);
+ return;
+ }
+
+ var channels = files
+ .Where(static file => file.Name.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
+ .OrderBy(static file => ChannelLayout.OrderIndex(Path.GetFileNameWithoutExtension(file.Name)))
+ .Select(static file => new WavReader().Read(file.Data))
+ .ToArray();
+ if (channels.Length is < 1 or > 8)
+ throw new InvalidOperationException("Ogg creation requires 1-8 mono WAV channels.");
+ var first = channels[0];
+ if (first.NumChannels != 1 || first.FormatCode != 1 || first.BitsPerSample != 16)
+ throw new InvalidOperationException("Ogg Vorbis/Opus creation requires PCM16 WAV input.");
+ if (channels.Any(channel => channel.NumChannels != 1 || channel.FormatCode != 1 ||
+ channel.BitsPerSample != 16 || channel.SampleRate != first.SampleRate ||
+ channel.InterleavedPcm.Length != first.InterleavedPcm.Length))
+ throw new InvalidOperationException("All Ogg channel WAVs must be PCM16 with matching rate and frame count.");
+
+ var interleaved = PcmCodec.Interleave(channels.Select(static channel => channel.InterleavedPcm).ToList(), 16);
+ var codec = options.Method ?? options.GetString("codec") ?? "vorbis";
+ this.EncodePcm(output,
+ new AudioPcmBuffer(new AudioPcmFormat(first.SampleRate, channels.Length, 16), interleaved),
+ codec, options);
+ }
+
+ public IReadOnlyList SupportedEncodeCodecs => EncodeCodecs;
+
+ public bool CanEncode(AudioPcmFormat format, string codecId, FormatCreateOptions options, out string? reason) {
+ if (!EncodeCodecs.Contains(codecId, StringComparer.OrdinalIgnoreCase)) {
+ reason = $"unsupported Ogg codec '{codecId}'";
+ return false;
+ }
+ if (format.Encoding != AudioPcmEncoding.SignedInteger || format.BitsPerSample != 16) {
+ reason = "managed Vorbis/Opus encoders currently accept signed PCM16";
+ return false;
+ }
+ if (format.Channels is < 1 or > 8 || format.SampleRate <= 0) {
+ reason = "Ogg audio creation supports 1-8 channels with a positive sample rate";
+ return false;
+ }
+ if (codecId.Equals("opus", StringComparison.OrdinalIgnoreCase) &&
+ format.SampleRate is not (8000 or 12000 or 16000 or 24000 or 48000)) {
+ reason = "Opus input rate must be 8, 12, 16, 24 or 48 kHz";
+ return false;
+ }
+ reason = null;
+ return true;
+ }
+ public void EncodePcm(Stream output, AudioPcmBuffer pcm, string codecId, FormatCreateOptions options) {
+ if (!this.CanEncode(pcm.Format, codecId, options, out var reason))
+ throw new NotSupportedException(reason);
+ var samples = ToShorts(pcm.InterleavedData);
+
+ if (codecId.Equals("vorbis", StringComparison.OrdinalIgnoreCase)) {
+ var quality = 0.5f;
+ var text = options.GetString("quality");
+ if (text is not null && !float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out quality))
+ throw new ArgumentException($"Invalid Vorbis quality '{text}'.", nameof(options));
+ var comments = ReadComments(options);
+ output.Write(VorbisEncoder.Encode(samples,
+ new VorbisEncoderOptions(pcm.Format.SampleRate, pcm.Format.Channels, quality, Comments: comments)));
+ return;
+ }
+
+ var bitrate = options.TryGetInt("bitrate", out var configuredBitrate) ? configuredBitrate : 128_000;
+ var complexity = options.TryGetInt("complexity", out var configuredComplexity) ? configuredComplexity : 10;
+ var vbr = options.GetString("vbr") is { } vbrText ? bool.Parse(vbrText) : true;
+ var constrainedVbr = options.GetString("constrained-vbr") is { } cvbrText && bool.Parse(cvbrText);
+ var dtx = options.GetString("dtx") is { } dtxText && bool.Parse(dtxText);
+ var fec = options.GetString("fec") is { } fecText && bool.Parse(fecText);
+ var loss = options.TryGetInt("packet-loss-percent", out var configuredLoss) ? configuredLoss : 0;
+ var duration = options.GetString("frame-ms") is { } frameText
+ ? double.Parse(frameText, CultureInfo.InvariantCulture)
+ : 20.0;
+ output.Write(OpusCodec.Encode(samples, new OpusEncoderOptions(
+ pcm.Format.SampleRate,
+ pcm.Format.Channels,
+ Bitrate: bitrate,
+ Complexity: complexity,
+ UseVbr: vbr,
+ ConstrainedVbr: constrainedVbr,
+ UseDtx: dtx,
+ UseInbandFec: fec,
+ PacketLossPercent: loss,
+ FrameDurationMilliseconds: duration)));
+ }
+
+ public AudioPcmBuffer DecodePcm(Stream input) {
+ var blob = ReadAll(input);
+ var isOpus = IndexOf(blob, "OpusHead"u8) >= 0;
+ var isSpeex = !isOpus && IndexOf(blob, "Speex "u8) >= 0;
+ int channels;
+ int sampleRate;
+ using var info = new MemoryStream(blob, writable: false);
+ using var source = new MemoryStream(blob, writable: false);
+ using var pcm = new MemoryStream();
+ if (isOpus) {
+ var metadata = OpusCodec.ReadStreamInfo(info);
+ channels = metadata.Channels;
+ sampleRate = metadata.SampleRate > 0 ? metadata.SampleRate : 48_000;
+ OpusCodec.Decompress(source, pcm);
+ } else if (isSpeex) {
+ var metadata = SpeexCodec.ReadStreamInfo(info);
+ channels = metadata.Channels;
+ sampleRate = metadata.SampleRate;
+ SpeexCodec.Decompress(source, pcm);
+ } else {
+ var metadata = VorbisCodec.ReadStreamInfo(info);
+ channels = metadata.Channels;
+ sampleRate = metadata.SampleRate;
+ VorbisCodec.Decompress(source, pcm);
+ }
+ return new AudioPcmBuffer(new AudioPcmFormat(sampleRate, channels, 16), pcm.ToArray());
+ }
+
+ private static IReadOnlyDictionary? ReadComments(FormatCreateOptions options) {
+ Dictionary? comments = null;
+ foreach (var (key, value) in options.FormatSpecific) {
+ if (!key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase)) continue;
+ comments ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ comments[key[4..]] = value;
+ }
+ return comments;
+ }
+
+ private static short[] ToShorts(byte[] data) {
+ if ((data.Length & 1) != 0) throw new InvalidDataException("PCM16 byte count must be even.");
+ var result = new short[data.Length / 2];
+ for (var i = 0; i < result.Length; ++i)
+ result[i] = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(i * 2, 2));
+ return result;
+ }
+
+ private static IReadOnlyList<(string Name, string Kind, byte[] Data)> BuildEntries(Stream stream) {
+ var blob = ReadAll(stream);
var entries = new List<(string Name, string Kind, byte[] Data)> {
("FULL.ogg", "Container", blob),
};
AddDecodedChannels(blob, entries);
-
var parser = new OggPageParser();
var pages = parser.Pages(blob);
- var serials = pages.Select(p => p.Serial).Distinct().ToArray();
+ var serials = pages.Select(static page => page.Serial).Distinct().ToArray();
foreach (var serial in serials) {
var packets = parser.StreamPackets(blob, serial).ToArray();
- entries.Add(($"stream_{serial:X8}/packets.bin",
- "Stream", ConcatenateWithLengthPrefix(packets)));
-
- // Vorbis: packet 1 is comment packet starting with 0x03 "vorbis".
- // Opus: packet 1 is "OpusTags".
- if (packets.Length >= 2) {
- var p1 = packets[1];
- (string Tag, int Offset)? probe =
- p1.Length >= 7 && p1[0] == 0x03 && Encoding.ASCII.GetString(p1, 1, 6) == "vorbis" ? ("vorbis", 7) :
- p1.Length >= 8 && Encoding.ASCII.GetString(p1, 0, 8) == "OpusTags" ? ("opus", 8) : null;
- if (probe != null) {
- var parsed = new VorbisCommentReader().Read(p1.AsSpan(probe.Value.Offset));
- var commentText = new StringBuilder();
- commentText.AppendLine($"Vendor: {parsed.Vendor}");
- foreach (var (k, v) in parsed.Comments) commentText.AppendLine($"{k}={v}");
- entries.Add(($"stream_{serial:X8}/comments.txt",
- "Tag", Encoding.UTF8.GetBytes(commentText.ToString())));
- }
- }
+ entries.Add(($"stream_{serial:X8}/packets.bin", "Stream", ConcatenateWithLengthPrefix(packets)));
+ if (packets.Length < 2) continue;
+ var p1 = packets[1];
+ (string Tag, int Offset)? probe =
+ p1.Length >= 7 && p1[0] == 0x03 && Encoding.ASCII.GetString(p1, 1, 6) == "vorbis" ? ("vorbis", 7) :
+ p1.Length >= 8 && Encoding.ASCII.GetString(p1, 0, 8) == "OpusTags" ? ("opus", 8) : null;
+ if (probe is null) continue;
+ var parsed = new VorbisCommentReader().Read(p1.AsSpan(probe.Value.Offset));
+ var commentText = new StringBuilder();
+ commentText.AppendLine($"Vendor: {parsed.Vendor}");
+ foreach (var (key, value) in parsed.Comments) commentText.AppendLine($"{key}={value}");
+ entries.Add(($"stream_{serial:X8}/comments.txt", "Tag", Encoding.UTF8.GetBytes(commentText.ToString())));
}
return entries;
}
- ///
- /// Detects the primary codec (Opus via the OpusHead identification packet,
- /// Speex via the Speex identification packet, otherwise
- /// Vorbis), decodes the whole bitstream to interleaved 16-bit PCM, and adds one mono
- /// <CHANNEL>.wav per channel. Anything the decoders reject (Vorbis floor
- /// 0, Opus hybrid, unsupported Speex profiles, multiplexed video, truncation) is
- /// skipped so only the raw packet blobs remain.
- ///
private static void AddDecodedChannels(byte[] blob, List<(string Name, string Kind, byte[] Data)> entries) {
try {
- var isOpus = IndexOf(blob, "OpusHead"u8) >= 0;
- var isSpeex = !isOpus && IndexOf(blob, "Speex "u8) >= 0;
- int channels, sampleRate;
- byte[] pcm;
-
- using (var info = new MemoryStream(blob, writable: false))
- using (var src = new MemoryStream(blob, writable: false))
- using (var dst = new MemoryStream()) {
- if (isOpus) {
- var i = OpusCodec.ReadStreamInfo(info);
- channels = i.Channels;
- sampleRate = i.SampleRate > 0 ? i.SampleRate : 48000;
- OpusCodec.Decompress(src, dst);
- } else if (isSpeex) {
- var i = SpeexCodec.ReadStreamInfo(info);
- channels = i.Channels;
- sampleRate = i.SampleRate;
- SpeexCodec.Decompress(src, dst);
- } else {
- var i = VorbisCodec.ReadStreamInfo(info);
- channels = i.Channels;
- sampleRate = i.SampleRate;
- VorbisCodec.Decompress(src, dst);
- }
- pcm = dst.ToArray();
- }
-
- if (channels < 1 || sampleRate <= 0 || pcm.Length == 0)
- return;
-
- if (channels == 1)
- entries.Add(("MONO.wav", "Channel", PcmCodec.ToWavBlob(pcm, 1, sampleRate, 16)));
+ using var source = new MemoryStream(blob, writable: false);
+ var descriptor = new OggFormatDescriptor();
+ var pcm = descriptor.DecodePcm(source);
+ if (pcm.InterleavedData.Length == 0) return;
+ if (pcm.Format.Channels == 1)
+ entries.Add(("MONO.wav", "Channel", PcmCodec.ToWavBlob(pcm.InterleavedData, 1, pcm.Format.SampleRate, 16)));
else
- foreach (var (name, wav) in PcmCodec.SplitInterleavedPcm(pcm, channels, sampleRate, 16))
+ foreach (var (name, wav) in PcmCodec.SplitInterleavedPcm(
+ pcm.InterleavedData, pcm.Format.Channels, pcm.Format.SampleRate, 16))
entries.Add(($"{name}.wav", "Channel", wav));
} catch {
- // Undecodable / unsupported / not a single-audio-stream Ogg — packet blobs only.
+ // Unsupported/multiplexed Ogg remains available through raw packet entries.
}
}
private static int IndexOf(byte[] haystack, ReadOnlySpan needle) {
for (var i = 0; i + needle.Length <= haystack.Length; ++i)
- if (haystack.AsSpan(i, needle.Length).SequenceEqual(needle))
- return i;
+ if (haystack.AsSpan(i, needle.Length).SequenceEqual(needle)) return i;
return -1;
}
- // Raw packets are stored length-prefixed so downstream tools can reconstruct
- // packet boundaries without re-running the page parser.
private static byte[] ConcatenateWithLengthPrefix(byte[][] packets) {
- using var ms = new MemoryStream();
- Span lenBuf = stackalloc byte[4];
- foreach (var p in packets) {
- System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(lenBuf, (uint)p.Length);
- ms.Write(lenBuf);
- ms.Write(p);
+ using var memory = new MemoryStream();
+ Span length = stackalloc byte[4];
+ foreach (var packet in packets) {
+ BinaryPrimitives.WriteUInt32LittleEndian(length, checked((uint)packet.Length));
+ memory.Write(length);
+ memory.Write(packet);
}
- return ms.ToArray();
+ return memory.ToArray();
+ }
+
+ private static byte[] ReadAll(Stream input) {
+ if (input.CanSeek) input.Position = 0;
+ using var memory = new MemoryStream();
+ input.CopyTo(memory);
+ return memory.ToArray();
}
}
diff --git a/FileFormats/FileFormat.Wav/WavReader.cs b/FileFormats/FileFormat.Wav/WavReader.cs
index 11e3b40e8..975d7bcee 100644
--- a/FileFormats/FileFormat.Wav/WavReader.cs
+++ b/FileFormats/FileFormat.Wav/WavReader.cs
@@ -4,25 +4,10 @@
namespace FileFormat.Wav;
///
-/// RIFF/WAVE header + per-channel PCM extraction. Supports format codes:
-///
-/// - 1 — linear PCM (8/16/24/32-bit).
-/// - 3 — IEEE float (32-bit / 64-bit).
-/// - 6 — G.711 A-law (decoded to 16-bit LE PCM via Codec.ALaw).
-/// - 7 — G.711 μ-law (decoded to 16-bit LE PCM via Codec.MuLaw).
-/// - 0x0002 — Microsoft ADPCM (decoded via Codec.MsAdpcm).
-/// - 0x0011 — IMA ADPCM (decoded via Codec.ImaAdpcm).
-/// - 0x0022 — DSP Group TrueSpeech (decoded to mono 16-bit PCM via Codec.TrueSpeech).
-/// - 0x0031 — GSM 06.10 full-rate (decoded via Codec.Gsm610).
-/// - 0xFFFE — WAVEFORMAT_EXTENSIBLE, real sub-format at +24 in fmt body.
-///
-/// After decoding, always holds little-endian
-/// integer samples and reflects the decoded
-/// width, so downstream callers (e.g. WavFormatDescriptor) see PCM regardless
-/// of the on-wire compression. also reflects the
-/// post-decode code (always 1 when we decoded).
-/// Reads only the fmt and data chunks; skips metadata chunks but
-/// leaves them addressable via .
+/// RIFF/WAVE header + per-channel PCM extraction. Supports linear PCM, IEEE float,
+/// G.711, IMA/MS ADPCM, TrueSpeech and GSM 06.10. Compressed formats are decoded
+/// to canonical little-endian PCM and trimmed to the optional fact sample
+/// count so codec block padding never leaks into downstream transcoding.
///
public sealed class WavReader {
public sealed record ParsedWav(
@@ -37,33 +22,34 @@ public sealed record ParsedWav(
public ParsedWav Read(ReadOnlySpan data) {
if (data.Length < 44)
throw new InvalidDataException("WAV too short for RIFF header + fmt/data chunks.");
- if (data[0] != 'R' || data[1] != 'I' || data[2] != 'F' || data[3] != 'F')
+ if (!data[..4].SequenceEqual("RIFF"u8))
throw new InvalidDataException("Missing RIFF magic.");
- if (data[8] != 'W' || data[9] != 'A' || data[10] != 'V' || data[11] != 'E')
+ if (!data.Slice(8, 4).SequenceEqual("WAVE"u8))
throw new InvalidDataException("RIFF payload is not WAVE.");
var pos = 12;
int formatCode = 0, numChannels = 0, sampleRate = 0, bitsPerSample = 0, blockAlign = 0;
uint? channelMask = null;
+ uint? factSampleFrames = null;
var fmtParsed = false;
byte[]? rawData = null;
var metadata = new List<(string, byte[])>();
while (pos + 8 <= data.Length) {
var id = System.Text.Encoding.ASCII.GetString(data.Slice(pos, 4));
- var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(data[(pos + 4)..]);
+ var size = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(data[(pos + 4)..]));
var bodyStart = pos + 8;
- if (bodyStart + size > data.Length)
+ if (size < 0 || bodyStart + (long)size > data.Length)
throw new InvalidDataException($"Chunk '{id}' truncated.");
switch (id) {
case "fmt ": {
+ if (size < 16) throw new InvalidDataException("WAV 'fmt ' chunk is shorter than 16 bytes.");
formatCode = BinaryPrimitives.ReadUInt16LittleEndian(data[bodyStart..]);
numChannels = BinaryPrimitives.ReadUInt16LittleEndian(data[(bodyStart + 2)..]);
- sampleRate = (int)BinaryPrimitives.ReadUInt32LittleEndian(data[(bodyStart + 4)..]);
+ sampleRate = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(data[(bodyStart + 4)..]));
blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(data[(bodyStart + 12)..]);
bitsPerSample = BinaryPrimitives.ReadUInt16LittleEndian(data[(bodyStart + 14)..]);
- // WAVE_FORMAT_EXTENSIBLE: dwChannelMask at +20, real code 24 bytes in.
if (formatCode == 0xFFFE && size >= 40) {
channelMask = BinaryPrimitives.ReadUInt32LittleEndian(data[(bodyStart + 20)..]);
formatCode = BinaryPrimitives.ReadUInt16LittleEndian(data[(bodyStart + 24)..]);
@@ -71,6 +57,11 @@ public ParsedWav Read(ReadOnlySpan data) {
fmtParsed = true;
break;
}
+ case "fact":
+ if (size >= 4)
+ factSampleFrames = BinaryPrimitives.ReadUInt32LittleEndian(data[bodyStart..]);
+ metadata.Add((id, data.Slice(bodyStart, size).ToArray()));
+ break;
case "data":
rawData = data.Slice(bodyStart, size).ToArray();
break;
@@ -78,69 +69,70 @@ public ParsedWav Read(ReadOnlySpan data) {
metadata.Add((id, data.Slice(bodyStart, size).ToArray()));
break;
}
- // Chunks are word-aligned: if size is odd, skip a pad byte.
pos = bodyStart + size + (size & 1);
}
if (!fmtParsed) throw new InvalidDataException("WAV missing 'fmt ' chunk.");
- if (rawData == null) throw new InvalidDataException("WAV missing 'data' chunk.");
+ if (rawData is null) throw new InvalidDataException("WAV missing 'data' chunk.");
+ if (numChannels < 1) throw new InvalidDataException("WAV channel count must be positive.");
+ if (sampleRate < 1) throw new InvalidDataException("WAV sample rate must be positive.");
- // Dispatch compressed formats → linear LE PCM.
switch (formatCode) {
- case 6: { // A-law
+ case 6: {
var shorts = Codec.ALaw.ALawCodec.Decode(rawData);
- return new ParsedWav(numChannels, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: ShortsToLePcm(shorts), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(numChannels, sampleRate, shorts, metadata, channelMask, factSampleFrames);
}
- case 7: { // μ-law
+ case 7: {
var shorts = Codec.MuLaw.MuLawCodec.Decode(rawData);
- return new ParsedWav(numChannels, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: ShortsToLePcm(shorts), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(numChannels, sampleRate, shorts, metadata, channelMask, factSampleFrames);
}
- case 0x0011: { // IMA ADPCM
+ case 0x0011: {
if (blockAlign <= 0) throw new InvalidDataException("IMA ADPCM needs blockAlign.");
var perChannel = Codec.ImaAdpcm.ImaAdpcmCodec.Decode(rawData, blockAlign, numChannels);
- return new ParsedWav(numChannels, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: InterleaveChannels(perChannel), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(numChannels, sampleRate, InterleaveChannels(perChannel, factSampleFrames), metadata, channelMask, null);
}
- case 0x0002: { // MS ADPCM
+ case 0x0002: {
if (blockAlign <= 0) throw new InvalidDataException("MS ADPCM needs blockAlign.");
var perChannel = Codec.MsAdpcm.MsAdpcmCodec.Decode(rawData, blockAlign, numChannels);
- return new ParsedWav(numChannels, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: InterleaveChannels(perChannel), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(numChannels, sampleRate, InterleaveChannels(perChannel, factSampleFrames), metadata, channelMask, null);
}
- case 0x0022: { // DSP Group TrueSpeech (mono 8 kHz; 32-byte frames → 240 samples)
+ case 0x0022: {
var shorts = Codec.TrueSpeech.TrueSpeechCodec.Decode(rawData);
- return new ParsedWav(NumChannels: 1, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: ShortsToLePcm(shorts), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(1, sampleRate, shorts, metadata, channelMask, factSampleFrames);
}
- case 0x0031: { // GSM 06.10
+ case 0x0031: {
var shorts = Codec.Gsm610.Gsm610Codec.Decode(rawData, numChannels);
- return new ParsedWav(numChannels, sampleRate, 16, FormatCode: 1,
- InterleavedPcm: ShortsToLePcm(shorts), MetadataChunks: metadata, ChannelMask: channelMask);
+ return Pcm16(numChannels, sampleRate, shorts, metadata, channelMask, factSampleFrames);
}
default:
return new ParsedWav(numChannels, sampleRate, bitsPerSample, formatCode, rawData, metadata, channelMask);
}
}
- private static byte[] ShortsToLePcm(ReadOnlySpan samples) {
- var pcm = new byte[samples.Length * 2];
- for (var i = 0; i < samples.Length; ++i)
- BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 2), samples[i]);
- return pcm;
+ private static ParsedWav Pcm16(int channels, int sampleRate, ReadOnlySpan samples,
+ IReadOnlyList<(string Id, byte[] Data)> metadata, uint? channelMask, uint? factSampleFrames) {
+ var sampleCount = samples.Length;
+ if (factSampleFrames is { } frames) {
+ var requested = Math.Min((long)sampleCount, (long)frames * channels);
+ sampleCount = checked((int)requested);
+ }
+ var pcm = new byte[sampleCount * 2];
+ for (var i = 0; i < sampleCount; ++i)
+ BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 2, 2), samples[i]);
+ return new ParsedWav(channels, sampleRate, 16, 1, pcm, metadata, channelMask);
}
- private static byte[] InterleaveChannels(short[][] perChannel) {
+ private static short[] InterleaveChannels(short[][] perChannel, uint? factSampleFrames) {
if (perChannel.Length == 0) return [];
- var ch = perChannel.Length;
- var frames = perChannel[0].Length;
- var pcm = new byte[frames * ch * 2];
- for (var f = 0; f < frames; ++f) {
- for (var c = 0; c < ch; ++c) {
- BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan((f * ch + c) * 2), perChannel[c][f]);
- }
- }
- return pcm;
+ var channels = perChannel.Length;
+ var availableFrames = perChannel.Min(static channel => channel.Length);
+ var frameCount = factSampleFrames is { } frames
+ ? checked((int)Math.Min((long)availableFrames, frames))
+ : availableFrames;
+ var samples = new short[checked(frameCount * channels)];
+ for (var frame = 0; frame < frameCount; ++frame)
+ for (var channel = 0; channel < channels; ++channel)
+ samples[frame * channels + channel] = perChannel[channel][frame];
+ return samples;
}
}