diff --git a/CLAUDE.md b/CLAUDE.md index 75d957b..551ba2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What is EggEncoder -EggEncoder is a .NET audio encoding/decoding toolkit built around a single `IMediaEncoder` abstraction (`Probe`, `ConvertFile`, `CutFile`) implemented entirely in-process by `NativeEncoder` — pure .NET + P/Invoke codec bindings, no external process, no ffmpeg dependency. Originally extracted from the DSP music distribution platform's `Dsp.Core.Encoder` project (which also had an ffmpeg-shell-out engine; that engine was dropped when EggEncoder became native-only). +EggEncoder is a .NET audio encoding/decoding toolkit built around a single `IMediaEncoder` abstraction (`Probe`, `ConvertFile`, `CutFile`) implemented entirely in-process by `NativeEncoder` — pure .NET + P/Invoke codec bindings, no external process, no ffmpeg dependency. Originally extracted from a music distribution platform's internal encoder project (which also had an ffmpeg-shell-out engine; that engine was dropped when EggEncoder became native-only). ## Commands @@ -39,7 +39,7 @@ dotnet test src/EggEncoder.UnitTests/EggEncoder.UnitTests.csproj --configuration ### Supporting infrastructure -- **`Transform/`** — `BitReader`, `BitWriter`, `HuffmanTable`, `Mdct` — low-level bitstream and DSP primitives shared by the AAC/WMA codecs +- **`Transform/`** — `BitReader`, `BitWriter`, `HuffmanTable`, `Mdct` — low-level bitstream and signal-processing primitives shared by the AAC/WMA codecs - **`Native/`** — `FlacNative.cs`/`Mp3Native.cs` (`[LibraryImport]` P/Invoke declarations), `NativeLibraryLoader.cs` (a `[ModuleInitializer]` that registers a custom `DllImportResolver` so `libFLAC`/`libmp3lame` load from `Native/win-x64/` relative to `AppContext.BaseDirectory` regardless of the consuming app's working directory) - **`Waveform/WaveformCalculator.cs`** — streaming peak-window calculator fed blocks during decode, used by every codec's probe path to produce `ProbeResult.WaveformResult` - **`Results/ProbeResult.cs`** — the public `ProbeResult` DTO returned by every `Probe` call diff --git a/src/EggEncoder.UnitTests/NativeEncoderTest.cs b/src/EggEncoder.UnitTests/NativeEncoderTest.cs index 8139f6a..4b70b05 100644 --- a/src/EggEncoder.UnitTests/NativeEncoderTest.cs +++ b/src/EggEncoder.UnitTests/NativeEncoderTest.cs @@ -12,6 +12,8 @@ public class NativeEncoderTest private static readonly string _wavFixturePath = Path.GetFullPath("Codecs/Flac/sample.wav"); private static readonly string _movFixturePath = Path.GetFullPath("Codecs/Mov/test.mov"); private static readonly string _mp4FixturePath = Path.GetFullPath("Codecs/Mov/test.mp4"); + private static readonly string _aacFixturePath = Path.GetFullPath("Codecs/Aac/tone_mono.aac"); + private static readonly string _wmaFixturePath = Path.GetFullPath("Codecs/Wma/tone_mono.wma"); private readonly Mock> _logger = new(); @@ -27,10 +29,19 @@ public async Task Probe_WavFile_Should_Return_Correct_Metadata_And_Waveform() { var probeResult = await _nativeEncoder.Probe(_wavFixturePath); + AssertNonEmptyWaveform(probeResult.WaveformResult); + + probeResult.FormatName.Should().Be("wav"); + probeResult.SizeBytes.Should().Be(new FileInfo(_wavFixturePath).Length); + probeResult.DurationSeconds.Should().BeApproximately(2, 0.1); + + probeResult.CodecType.Should().Be("audio"); + probeResult.CodecName.Should().Be("pcm_s16le"); probeResult.SampleRate.Should().Be(44100); + probeResult.Channels.Should().Be(2); + probeResult.ChannelLayout.Should().Be("stereo"); probeResult.BitsPerSample.Should().Be(16); - probeResult.DurationInSeconds.Should().Be(2); - AssertNonEmptyWaveform(probeResult.WaveformResult); + probeResult.TimeBase.Should().Be("1/44100"); } [Fact] @@ -45,10 +56,14 @@ public async Task Probe_FlacFile_Should_Return_Correct_Metadata_And_Waveform() var probeResult = await _nativeEncoder.Probe(flacPath); + AssertNonEmptyWaveform(probeResult.WaveformResult); + + probeResult.FormatName.Should().Be("flac"); + probeResult.DurationSeconds.Should().BeApproximately(2, 0.1); + probeResult.CodecName.Should().Be("flac"); probeResult.SampleRate.Should().Be(44100); probeResult.BitsPerSample.Should().Be(16); - probeResult.DurationInSeconds.Should().Be(2); - AssertNonEmptyWaveform(probeResult.WaveformResult); + probeResult.ChannelLayout.Should().Be("stereo"); } finally { @@ -68,10 +83,14 @@ public async Task Probe_Mp3File_Should_Return_Correct_Metadata_And_Waveform() var probeResult = await _nativeEncoder.Probe(mp3Path); + AssertNonEmptyWaveform(probeResult.WaveformResult); + + probeResult.FormatName.Should().Be("mp3"); + probeResult.DurationSeconds.Should().BeApproximately(2, 0.1); + probeResult.CodecName.Should().Be("mp3"); probeResult.SampleRate.Should().Be(44100); probeResult.BitsPerSample.Should().Be(16); - probeResult.DurationInSeconds.Should().Be(2); - AssertNonEmptyWaveform(probeResult.WaveformResult); + probeResult.ChannelLayout.Should().Be("stereo"); } finally { @@ -79,15 +98,41 @@ public async Task Probe_Mp3File_Should_Return_Correct_Metadata_And_Waveform() } } + [Fact] + public async Task Probe_AacFile_Should_Return_Correct_Metadata() + { + var probeResult = await _nativeEncoder.Probe(_aacFixturePath); + + probeResult.FormatName.Should().Be("aac"); + probeResult.SampleRate.Should().BePositive(); + probeResult.CodecName.Should().Be("aac"); + probeResult.CodecType.Should().Be("audio"); + probeResult.ChannelLayout.Should().Be("mono"); + } + + [Fact] + public async Task Probe_WmaFile_Should_Return_Correct_Metadata() + { + var probeResult = await _nativeEncoder.Probe(_wmaFixturePath); + + probeResult.FormatName.Should().Be("asf"); + probeResult.SampleRate.Should().BePositive(); + probeResult.CodecName.Should().Be("wmav2"); + probeResult.CodecType.Should().Be("audio"); + } + [Fact] public async Task Probe_MovFile_Should_Return_Correct_VideoMetadata() { var probeResult = await _nativeEncoder.Probe(_movFixturePath); - probeResult.DurationInSeconds.Should().Be(5); + probeResult.WaveformResult.Should().BeNull(); + + probeResult.FormatName.Should().Be("mov"); + probeResult.DurationSeconds.Should().BeApproximately(5, 0.1); + probeResult.CodecType.Should().Be("video"); probeResult.Width.Should().Be(640); probeResult.Height.Should().Be(360); - probeResult.WaveformResult.Should().BeNull(); } [Fact] @@ -95,10 +140,12 @@ public async Task Probe_Mp4File_Should_Return_Correct_VideoMetadata() { var probeResult = await _nativeEncoder.Probe(_mp4FixturePath); - probeResult.DurationInSeconds.Should().Be(5); + probeResult.WaveformResult.Should().BeNull(); probeResult.Width.Should().Be(640); probeResult.Height.Should().Be(360); - probeResult.WaveformResult.Should().BeNull(); + + probeResult.FormatName.Should().Be("mp4"); + probeResult.DurationSeconds.Should().BeApproximately(5, 0.1); } [Fact] diff --git a/src/EggEncoder/Codecs/Mov/MovProbe.cs b/src/EggEncoder/Codecs/Mov/MovProbe.cs index 90045b3..2e5cca9 100644 --- a/src/EggEncoder/Codecs/Mov/MovProbe.cs +++ b/src/EggEncoder/Codecs/Mov/MovProbe.cs @@ -46,9 +46,12 @@ public static MovProbeResult Probe(string filePath) break; } + var durationSeconds = timescale > 0 ? duration / (double)timescale : 0; + return new MovProbeResult { - DurationInSeconds = timescale > 0 ? (int)(duration / timescale) : 0, + DurationInSeconds = (int)durationSeconds, + DurationSeconds = durationSeconds, Width = width, Height = height, CodecFourCc = codecFourCc @@ -162,6 +165,8 @@ public class MovProbeResult { public required int DurationInSeconds { get; init; } + public required double DurationSeconds { get; init; } + public required int? Width { get; init; } public required int? Height { get; init; } diff --git a/src/EggEncoder/Codecs/Mp3/Mp3Probe.cs b/src/EggEncoder/Codecs/Mp3/Mp3Probe.cs index 3d1612d..152f71e 100644 --- a/src/EggEncoder/Codecs/Mp3/Mp3Probe.cs +++ b/src/EggEncoder/Codecs/Mp3/Mp3Probe.cs @@ -26,7 +26,7 @@ public static Mp3ProbeResult Probe(string filePath) var trailingTagSize = HasId3V1Tag(stream) ? 128 : 0; var audioDataLength = stream.Length - frame.Offset - trailingTagSize; - int durationInSeconds; + double durationSeconds; int bitRateKbps; bool isVariableBitRate; @@ -34,22 +34,23 @@ public static Mp3ProbeResult Probe(string filePath) { var samplesPerFrame = frame.Header.IsMpeg1 ? 1152 : 576; var totalSamples = (long)knownVbrInfo.FrameCount * samplesPerFrame; - durationInSeconds = (int)(totalSamples / frame.Header.SampleRate); - bitRateKbps = durationInSeconds > 0 && knownVbrInfo.ByteCount > 0 - ? (int)(knownVbrInfo.ByteCount * 8 / 1000 / durationInSeconds) + durationSeconds = (double)totalSamples / frame.Header.SampleRate; + bitRateKbps = durationSeconds > 0 && knownVbrInfo.ByteCount > 0 + ? (int)(knownVbrInfo.ByteCount * 8 / 1000 / durationSeconds) : frame.Header.BitRateKbps; isVariableBitRate = knownVbrInfo.IsVbr; } else { - durationInSeconds = (int)(audioDataLength * 8 / 1000 / frame.Header.BitRateKbps); + durationSeconds = (double)audioDataLength * 8 / (frame.Header.BitRateKbps * 1000.0); bitRateKbps = frame.Header.BitRateKbps; isVariableBitRate = false; } return new Mp3ProbeResult { - DurationInSeconds = durationInSeconds, + DurationInSeconds = (int)durationSeconds, + DurationSeconds = durationSeconds, SampleRate = frame.Header.SampleRate, Channels = frame.Header.Channels, BitRate = bitRateKbps * 1000, @@ -203,6 +204,8 @@ public class Mp3ProbeResult { public required int DurationInSeconds { get; init; } + public required double DurationSeconds { get; init; } + public required int SampleRate { get; init; } public required int Channels { get; init; } diff --git a/src/EggEncoder/Codecs/Wav/WavReader.cs b/src/EggEncoder/Codecs/Wav/WavReader.cs index def4dc6..9039cf2 100644 --- a/src/EggEncoder/Codecs/Wav/WavReader.cs +++ b/src/EggEncoder/Codecs/Wav/WavReader.cs @@ -37,6 +37,8 @@ private WavReader(FileStream stream, int channels, int sampleRate, int bitsPerSa public long TotalSamples { get; } + public bool IsFloatFormat => _isFloatFormat; + public static WavReader Open(string filePath) { var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); diff --git a/src/EggEncoder/EggEncoder.csproj b/src/EggEncoder/EggEncoder.csproj index 65c899e..9274711 100644 --- a/src/EggEncoder/EggEncoder.csproj +++ b/src/EggEncoder/EggEncoder.csproj @@ -39,10 +39,18 @@ - + + PreserveNewest - + PreserveNewest diff --git a/src/EggEncoder/NativeEncoder.cs b/src/EggEncoder/NativeEncoder.cs index 03d4c77..cd914b3 100644 --- a/src/EggEncoder/NativeEncoder.cs +++ b/src/EggEncoder/NativeEncoder.cs @@ -39,7 +39,7 @@ public Task Probe(string filePath) _ => throw new NotSupportedException($"Probing '{extension}' files is not supported by the native audio encoder") }; - _logger.LogInformation($"Probe '{filePath}', result: '{result.Result}'"); + _logger.LogInformation($"Probe '{filePath}', format: '{result.FormatName}', codec: '{result.CodecName}', duration: {result.DurationSeconds}s"); return Task.FromResult(result); } @@ -108,17 +108,25 @@ private static ProbeResult ProbeWav(string filePath) waveformCalculator.AddBlock(new ReadOnlySpan(buffer, 0, framesRead * wavReader.Channels)); } - var durationInSeconds = wavReader.SampleRate > 0 ? (int)(wavReader.TotalSamples / wavReader.SampleRate) : 0; + var durationSeconds = wavReader.SampleRate > 0 ? (double)wavReader.TotalSamples / wavReader.SampleRate : 0; + var (codecName, codecLongName) = DescribeWavCodec(wavReader.BitsPerSample, wavReader.IsFloatFormat); return new ProbeResult { - DurationInSeconds = durationInSeconds, + FormatName = "wav", + FormatLongName = "WAV / WAVE (Waveform Audio)", + SizeBytes = GetFileSize(filePath), + DurationSeconds = durationSeconds, + CodecType = "audio", + CodecName = codecName, + CodecLongName = codecLongName, + SampleRate = wavReader.SampleRate, + Channels = wavReader.Channels, + ChannelLayout = DescribeChannelLayout(wavReader.Channels), BitsPerSample = wavReader.BitsPerSample, BitRate = wavReader.SampleRate * wavReader.BitsPerSample * wavReader.Channels, - SampleRate = wavReader.SampleRate, - Height = null, - Width = null, - Result = JsonSerializer.Serialize(new { wavReader.Channels, wavReader.SampleRate, wavReader.BitsPerSample }), + DurationInSamples = wavReader.TotalSamples, + TimeBase = wavReader.SampleRate > 0 ? $"1/{wavReader.SampleRate}" : null, WaveformResult = JsonSerializer.Serialize(waveformCalculator.GetNormalizedWindows()) }; } @@ -133,17 +141,24 @@ private static ProbeResult ProbeFlac(string filePath) waveformCalculator.AddBlock(block); }); - var durationInSeconds = streamInfo.SampleRate > 0 ? (int)(streamInfo.TotalSamples / streamInfo.SampleRate) : 0; + var durationSeconds = streamInfo.SampleRate > 0 ? (double)streamInfo.TotalSamples / streamInfo.SampleRate : 0; return new ProbeResult { - DurationInSeconds = durationInSeconds, + FormatName = "flac", + FormatLongName = "FLAC (Free Lossless Audio Codec)", + SizeBytes = GetFileSize(filePath), + DurationSeconds = durationSeconds, + CodecType = "audio", + CodecName = "flac", + CodecLongName = "FLAC (Free Lossless Audio Codec)", + SampleRate = streamInfo.SampleRate, + Channels = streamInfo.Channels, + ChannelLayout = DescribeChannelLayout(streamInfo.Channels), BitsPerSample = streamInfo.BitsPerSample, BitRate = streamInfo.SampleRate * streamInfo.BitsPerSample * streamInfo.Channels, - SampleRate = streamInfo.SampleRate, - Height = null, - Width = null, - Result = JsonSerializer.Serialize(new { streamInfo.Channels, streamInfo.SampleRate, streamInfo.BitsPerSample }), + DurationInSamples = streamInfo.TotalSamples, + TimeBase = streamInfo.SampleRate > 0 ? $"1/{streamInfo.SampleRate}" : null, WaveformResult = JsonSerializer.Serialize(waveformCalculator?.GetNormalizedWindows() ?? []) }; } @@ -161,13 +176,20 @@ private static ProbeResult ProbeMp3(string filePath) return new ProbeResult { - DurationInSeconds = mp3ProbeResult.DurationInSeconds, + FormatName = "mp3", + FormatLongName = "MP3 (MPEG audio layer 3)", + SizeBytes = GetFileSize(filePath), + DurationSeconds = mp3ProbeResult.DurationSeconds, + CodecType = "audio", + CodecName = "mp3", + CodecLongName = "MP3 (MPEG audio layer 3)", + SampleRate = mp3ProbeResult.SampleRate, + Channels = mp3ProbeResult.Channels, + ChannelLayout = DescribeChannelLayout(mp3ProbeResult.Channels), BitsPerSample = 16, BitRate = mp3ProbeResult.BitRate, - SampleRate = mp3ProbeResult.SampleRate, - Height = null, - Width = null, - Result = JsonSerializer.Serialize(mp3ProbeResult), + IsVariableBitRate = mp3ProbeResult.IsVariableBitRate, + TimeBase = mp3ProbeResult.SampleRate > 0 ? $"1/{mp3ProbeResult.SampleRate}" : null, WaveformResult = JsonSerializer.Serialize(waveformCalculator?.GetNormalizedWindows() ?? []) }; } @@ -182,17 +204,24 @@ private static ProbeResult ProbeAac(string filePath) waveformCalculator.AddBlock(block); }); - var durationInSeconds = streamInfo.SampleRate > 0 ? (int)(streamInfo.TotalSamples / streamInfo.SampleRate) : 0; + var durationSeconds = streamInfo.SampleRate > 0 ? (double)streamInfo.TotalSamples / streamInfo.SampleRate : 0; return new ProbeResult { - DurationInSeconds = durationInSeconds, + FormatName = "aac", + FormatLongName = "ADTS AAC (Advanced Audio Coding)", + SizeBytes = GetFileSize(filePath), + DurationSeconds = durationSeconds, + CodecType = "audio", + CodecName = "aac", + CodecLongName = "AAC-LC (Advanced Audio Coding, Low Complexity profile)", + SampleRate = streamInfo.SampleRate, + Channels = streamInfo.Channels, + ChannelLayout = DescribeChannelLayout(streamInfo.Channels), BitsPerSample = streamInfo.BitsPerSample, BitRate = streamInfo.SampleRate * streamInfo.BitsPerSample * streamInfo.Channels, - SampleRate = streamInfo.SampleRate, - Height = null, - Width = null, - Result = JsonSerializer.Serialize(new { streamInfo.Channels, streamInfo.SampleRate, streamInfo.BitsPerSample }), + DurationInSamples = streamInfo.TotalSamples, + TimeBase = streamInfo.SampleRate > 0 ? $"1/{streamInfo.SampleRate}" : null, WaveformResult = JsonSerializer.Serialize(waveformCalculator?.GetNormalizedWindows() ?? []) }; } @@ -207,17 +236,24 @@ private static ProbeResult ProbeWma(string filePath) waveformCalculator.AddBlock(block); }); - var durationInSeconds = streamInfo.SampleRate > 0 ? (int)(streamInfo.TotalSamples / streamInfo.SampleRate) : 0; + var durationSeconds = streamInfo.SampleRate > 0 ? (double)streamInfo.TotalSamples / streamInfo.SampleRate : 0; return new ProbeResult { - DurationInSeconds = durationInSeconds, + FormatName = "asf", + FormatLongName = "ASF (Advanced / Active Streaming Format)", + SizeBytes = GetFileSize(filePath), + DurationSeconds = durationSeconds, + CodecType = "audio", + CodecName = "wmav2", + CodecLongName = "Windows Media Audio 2", + SampleRate = streamInfo.SampleRate, + Channels = streamInfo.Channels, + ChannelLayout = DescribeChannelLayout(streamInfo.Channels), BitsPerSample = streamInfo.BitsPerSample, BitRate = streamInfo.SampleRate * streamInfo.BitsPerSample * streamInfo.Channels, - SampleRate = streamInfo.SampleRate, - Height = null, - Width = null, - Result = JsonSerializer.Serialize(new { streamInfo.Channels, streamInfo.SampleRate, streamInfo.BitsPerSample }), + DurationInSamples = streamInfo.TotalSamples, + TimeBase = streamInfo.SampleRate > 0 ? $"1/{streamInfo.SampleRate}" : null, WaveformResult = JsonSerializer.Serialize(waveformCalculator?.GetNormalizedWindows() ?? []) }; } @@ -225,18 +261,58 @@ private static ProbeResult ProbeWma(string filePath) private static ProbeResult ProbeVideo(string filePath) { var movProbeResult = MovProbe.Probe(filePath); + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + + var (formatName, formatLongName) = extension switch + { + ".mp4" => ("mp4", "MP4 (MPEG-4 Part 14)"), + _ => ("mov", "QuickTime / MOV") + }; return new ProbeResult { - DurationInSeconds = movProbeResult.DurationInSeconds, - BitsPerSample = null, - BitRate = null, - SampleRate = null, - Height = movProbeResult.Height, + FormatName = formatName, + FormatLongName = formatLongName, + SizeBytes = GetFileSize(filePath), + DurationSeconds = movProbeResult.DurationSeconds, + CodecType = "video", + CodecName = movProbeResult.CodecFourCc, Width = movProbeResult.Width, - Result = JsonSerializer.Serialize(movProbeResult), + Height = movProbeResult.Height, WaveformResult = null }; } + + private static (string CodecName, string CodecLongName) DescribeWavCodec(int bitsPerSample, bool isFloatFormat) + { + if (isFloatFormat) + { + return ("pcm_f32le", "PCM 32-bit floating-point little-endian"); + } + + return bitsPerSample switch + { + 8 => ("pcm_u8", "PCM unsigned 8-bit"), + 16 => ("pcm_s16le", "PCM signed 16-bit little-endian"), + 24 => ("pcm_s24le", "PCM signed 24-bit little-endian"), + 32 => ("pcm_s32le", "PCM signed 32-bit little-endian"), + _ => ($"pcm_s{bitsPerSample}le", $"PCM signed {bitsPerSample}-bit little-endian") + }; + } + + private static string DescribeChannelLayout(int channels) + { + return channels switch + { + 1 => "mono", + 2 => "stereo", + _ => $"{channels} channels" + }; + } + + private static long GetFileSize(string filePath) + { + return new FileInfo(filePath).Length; + } } } diff --git a/src/EggEncoder/Results/ProbeResult.cs b/src/EggEncoder/Results/ProbeResult.cs index aaccbd9..de5d580 100644 --- a/src/EggEncoder/Results/ProbeResult.cs +++ b/src/EggEncoder/Results/ProbeResult.cs @@ -2,19 +2,39 @@ namespace EggEncoder.Results { public class ProbeResult { - public required int DurationInSeconds { get; set; } + public required string FormatName { get; init; } - public required int? BitsPerSample { get; set; } + public required string FormatLongName { get; init; } - public required int? BitRate { get; set; } + public required long SizeBytes { get; init; } - public required int? SampleRate { get; set; } + public required double DurationSeconds { get; init; } - public required int? Height { get; set; } + public required string CodecType { get; init; } - public required int? Width { get; set; } + public string? CodecName { get; init; } - public required string? Result { get; set; } + public string? CodecLongName { get; init; } + + public int? SampleRate { get; init; } + + public int? Channels { get; init; } + + public string? ChannelLayout { get; init; } + + public int? BitsPerSample { get; init; } + + public int? BitRate { get; init; } + + public bool? IsVariableBitRate { get; init; } + + public long? DurationInSamples { get; init; } + + public string? TimeBase { get; init; } + + public int? Width { get; init; } + + public int? Height { get; init; } public string? WaveformResult { get; set; } }