Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
67 changes: 57 additions & 10 deletions src/EggEncoder.UnitTests/NativeEncoderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILogger<NativeEncoder>> _logger = new();

Expand All @@ -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]
Expand All @@ -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
{
Expand All @@ -68,37 +83,69 @@ 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
{
Directory.Delete(tempDirectory, recursive: true);
}
}

[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]
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]
Expand Down
7 changes: 6 additions & 1 deletion src/EggEncoder/Codecs/Mov/MovProbe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; }
Expand Down
15 changes: 9 additions & 6 deletions src/EggEncoder/Codecs/Mp3/Mp3Probe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,31 @@ 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;

if (vbrInfo is { FrameCount: > 0 } knownVbrInfo)
{
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,
Expand Down Expand Up @@ -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; }
Expand Down
2 changes: 2 additions & 0 deletions src/EggEncoder/Codecs/Wav/WavReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions src/EggEncoder/EggEncoder.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,18 @@
</ItemGroup>

<ItemGroup>
<None Include="Native\win-x64\libmp3lame.dll" Pack="true" PackagePath="contentFiles\any\any\Native\win-x64\libmp3lame.dll">
<!--
PackageCopyToOutput is what actually makes NuGet copy these into a consuming project's
output directory on restore (emits copyToOutput="true" in the packed .nuspec's contentFiles
element). Without it, the DLLs land in the package but only ever get copied via MSBuild's own
CopyToOutputDirectory item propagation, which only fires for in-repo ProjectReference
consumers (e.g. EggEncoder.UnitTests), not for real PackageReference consumers, who would get
a DllNotFoundException at P/Invoke time instead.
-->
<None Include="Native\win-x64\libmp3lame.dll" Pack="true" PackagePath="contentFiles\any\any\Native\win-x64\libmp3lame.dll" PackageCopyToOutput="true">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="Native\win-x64\libFLAC.dll" Pack="true" PackagePath="contentFiles\any\any\Native\win-x64\libFLAC.dll">
<None Include="Native\win-x64\libFLAC.dll" Pack="true" PackagePath="contentFiles\any\any\Native\win-x64\libFLAC.dll" PackageCopyToOutput="true">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
Expand Down
Loading
Loading