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; } }