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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ CodeCoverage/
*.VisualState.xml
TestResult.xml
nunit-*.xml

# BenchmarkDotNet
BenchmarkDotNet.Artifacts/
1 change: 1 addition & 0 deletions EggEncoder.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
<Folder Name="/src/">
<Project Path="src/EggEncoder/EggEncoder.csproj" />
<Project Path="src/EggEncoder.UnitTests/EggEncoder.UnitTests.csproj" />
<Project Path="src/EggEncoder.Benchmarks/EggEncoder.Benchmarks.csproj" />
</Folder>
</Solution>
49 changes: 49 additions & 0 deletions src/EggEncoder.Benchmarks/AacStreamingBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using BenchmarkDotNet.Attributes;
using EggEncoder.Codecs.Aac;

namespace EggEncoder.Benchmarks
{
// The AAC session is the encoder most directly used in a real-time/streaming pipeline --
// this measures per-sample push throughput and allocation behavior of that hot path.
[MemoryDiagnoser]
public class AacStreamingBenchmarks
{
private const int SampleRate = 44100;
private const int TotalSamples = SampleRate * 5;

private int[] _samples = null!;

[GlobalSetup]
public void Setup()
{
_samples = new int[TotalSamples];
for (var i = 0; i < TotalSamples; i++)
{
_samples[i] = (short)(10000 * Math.Sin(2 * Math.PI * 440 * i / SampleRate));
}
}

[Benchmark(Description = "AacEncoderSession: stream 5s mono PCM incrementally")]
public void StreamFiveSecondsMono()
{
var path = Path.Combine(Path.GetTempPath(), $"bench_aac_{Guid.NewGuid():N}.aac");
try
{
using var session = AacEncoderSession.OpenSession(path, channels: 1, SampleRate);

const int block = 1024;
for (var offset = 0; offset < _samples.Length; offset += block)
{
var count = Math.Min(block, _samples.Length - offset);
session.WriteInterleavedSamples(_samples[offset..(offset + count)], count);
}

session.Finish();
}
finally
{
File.Delete(path);
}
}
}
}
61 changes: 61 additions & 0 deletions src/EggEncoder.Benchmarks/AudioCutterBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using BenchmarkDotNet.Attributes;
using EggEncoder.Codecs;
using EggEncoder.Codecs.Wav;

namespace EggEncoder.Benchmarks
{
// End-to-end throughput for the decode-block -> encode-block pipeline used by
// NativeEncoder.ConvertFile/CutFile -- the path a real-time transcode call actually runs.
[MemoryDiagnoser]
public class AudioCutterBenchmarks
{
private const int Channels = 2;
private const int SampleRate = 44100;
private const int TotalFrames = SampleRate * 10;

private string _wavPath = null!;
private string _destMp3Path = null!;
private string _destFlacPath = null!;

[GlobalSetup]
public void Setup()
{
var pcm = new int[TotalFrames * Channels];
for (var frame = 0; frame < TotalFrames; frame++)
{
var sample = (short)(10000 * Math.Sin(2 * Math.PI * 440 * frame / SampleRate));
pcm[frame * Channels] = sample;
pcm[(frame * Channels) + 1] = sample;
}

_wavPath = Path.Combine(Path.GetTempPath(), $"bench_cutter_source_{Guid.NewGuid():N}.wav");
using (var writer = WavWriter.Create(_wavPath, Channels, SampleRate, bitsPerSample: 16, TotalFrames))
{
writer.WriteInterleavedSamples(pcm, TotalFrames);
}

_destMp3Path = Path.Combine(Path.GetTempPath(), $"bench_cutter_dest_{Guid.NewGuid():N}.mp3");
_destFlacPath = Path.Combine(Path.GetTempPath(), $"bench_cutter_dest_{Guid.NewGuid():N}.flac");
}

[GlobalCleanup]
public void Cleanup()
{
File.Delete(_wavPath);
File.Delete(_destMp3Path);
File.Delete(_destFlacPath);
}

[Benchmark(Description = "Convert 10s WAV -> MP3")]
public void ConvertWavToMp3()
{
AudioCutter.Convert(_wavPath, _destMp3Path);
}

[Benchmark(Description = "Convert 10s WAV -> FLAC")]
public void ConvertWavToFlac()
{
AudioCutter.Convert(_wavPath, _destFlacPath);
}
}
}
15 changes: 15 additions & 0 deletions src/EggEncoder.Benchmarks/EggEncoder.Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<NoWarn>CA1050</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<ProjectReference Include="..\EggEncoder\EggEncoder.csproj" />
</ItemGroup>

</Project>
3 changes: 3 additions & 0 deletions src/EggEncoder.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
84 changes: 84 additions & 0 deletions src/EggEncoder.Benchmarks/WavIoBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using BenchmarkDotNet.Attributes;
using EggEncoder.Codecs.Wav;

namespace EggEncoder.Benchmarks
{
// Covers the WAV read/write path every codec bottlenecks through for probe/convert/cut.
[MemoryDiagnoser]
public class WavIoBenchmarks
{
private const int Channels = 2;
private const int SampleRate = 44100;
private const int TotalFrames = SampleRate * 10;
private const int FramesPerBlock = 4096;

private string _wavPath = null!;
private int[] _pcm = null!;
private int[] _readBuffer = null!;

[GlobalSetup]
public void Setup()
{
_pcm = new int[TotalFrames * Channels];
var random = new Random(42);
for (var i = 0; i < _pcm.Length; i++)
{
_pcm[i] = (short)random.Next(short.MinValue, short.MaxValue);
}

_readBuffer = new int[FramesPerBlock * Channels];
_wavPath = Path.Combine(Path.GetTempPath(), $"bench_wav_{Guid.NewGuid():N}.wav");

using var writer = WavWriter.Create(_wavPath, Channels, SampleRate, bitsPerSample: 16, TotalFrames);
WriteAllBlocks(writer);
}

[GlobalCleanup]
public void Cleanup()
{
File.Delete(_wavPath);
}

[Benchmark(Description = "WavReader: read 10s stereo/16-bit in 4096-frame blocks")]
public long ReadAllBlocks()
{
using var reader = WavReader.Open(_wavPath);

long totalFrames = 0;
int framesRead;
while ((framesRead = reader.ReadInterleavedSamples(_readBuffer, FramesPerBlock)) > 0)
{
totalFrames += framesRead;
}

return totalFrames;
}

[Benchmark(Description = "WavWriter: write 10s stereo/16-bit in 4096-frame blocks")]
public void WriteAllBlocksBenchmark()
{
var path = Path.Combine(Path.GetTempPath(), $"bench_wav_write_{Guid.NewGuid():N}.wav");
try
{
using var writer = WavWriter.Create(path, Channels, SampleRate, bitsPerSample: 16, TotalFrames);
WriteAllBlocks(writer);
}
finally
{
File.Delete(path);
}
}

private void WriteAllBlocks(WavWriter writer)
{
var block = new int[FramesPerBlock * Channels];

for (var frameOffset = 0; frameOffset < TotalFrames; frameOffset += FramesPerBlock)
{
var framesThisBlock = Math.Min(FramesPerBlock, TotalFrames - frameOffset);
Array.Copy(_pcm, frameOffset * Channels, block, 0, framesThisBlock * Channels);
writer.WriteInterleavedSamples(block, framesThisBlock);
}
}
}
}
61 changes: 61 additions & 0 deletions src/EggEncoder.UnitTests/Codecs/Aac/AacEncoderSessionTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using EggEncoder.Codecs.Aac;
using FluentAssertions;

namespace EggEncoder.UnitTests.Codecs.Aac
{
public class AacEncoderSessionTest
{
[Fact]
public void WriteInterleavedSamples_PastOneFrame_Should_Flush_To_Disk_Before_Finish()
{
const int sampleRate = 44100;

var outputPath = Path.Combine(Path.GetTempPath(), $"aac_streaming_{Guid.NewGuid():N}.aac");
try
{
using var session = AacEncoderSession.OpenSession(outputPath, channels: 1, sampleRate: sampleRate);

// A single AAC-LC frame covers 1024 samples; writing more than that must emit at
// least one ADTS frame to disk immediately, without waiting for Finish().
var buffer = new int[2000];
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = (short)(1000 * Math.Sin(2 * Math.PI * 440 * i / sampleRate));
}

session.WriteInterleavedSamples(buffer, buffer.Length);

session.BytesWrittenForTesting.Should().BeGreaterThan(0, "the session should write completed frames to the stream as they're encoded, not buffer the whole track until Finish()");
}
finally
{
if (File.Exists(outputPath))
{
File.Delete(outputPath);
}
}
}

[Fact]
public void OpenSession_WithUnsupportedSampleRate_Should_Throw_Immediately()
{
var outputPath = Path.Combine(Path.GetTempPath(), $"aac_invalid_{Guid.NewGuid():N}.aac");

var act = () => AacEncoderSession.OpenSession(outputPath, channels: 1, sampleRate: 12345);

act.Should().ThrowExactly<NotSupportedException>();
File.Exists(outputPath).Should().BeFalse("validation should fail before any file is created");
}

[Fact]
public void OpenSession_WithStereoChannels_Should_Throw_Immediately()
{
var outputPath = Path.Combine(Path.GetTempPath(), $"aac_invalid_{Guid.NewGuid():N}.aac");

var act = () => AacEncoderSession.OpenSession(outputPath, channels: 2, sampleRate: 44100);

act.Should().ThrowExactly<NotSupportedException>();
File.Exists(outputPath).Should().BeFalse("validation should fail before any file is created");
}
}
}
25 changes: 22 additions & 3 deletions src/EggEncoder.UnitTests/Codecs/AudioCutterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,29 @@ public void Cut_UnsupportedExtension_Should_Throw()
}

[Fact]
public void Cut_MismatchedExtensions_Should_Throw()
public void Cut_WavToMp3_CrossFormat_Should_Produce_Trimmed_NonSilent_Output()
{
var act = () => AudioCutter.Cut("source.wav", "dest.mp3", 0, 10);
act.Should().ThrowExactly<NotSupportedException>();
var tempDirectory = CreateTempDirectory();

try
{
var destMp3Path = Path.Combine(tempDirectory, "cut.mp3");

AudioCutter.Cut(_wavFixturePath, destMp3Path, startInSeconds: 0, endInSeconds: 1).Should().BeTrue();

var probeResult = Mp3Probe.Probe(destMp3Path);
probeResult.SampleRate.Should().Be(44100);
probeResult.Channels.Should().Be(2);
probeResult.DurationInSeconds.Should().Be(1);

var (_, samples) = Mp3TestDecoder.DecodeAll(destMp3Path);
var rootMeanSquare = Math.Sqrt(samples.Average(sample => (double)sample * sample));
rootMeanSquare.Should().BeGreaterThan(1000, $"expected a real, non-silent decoded signal, got RMS={rootMeanSquare}");
}
finally
{
Directory.Delete(tempDirectory, recursive: true);
}
}

[Fact]
Expand Down
54 changes: 53 additions & 1 deletion src/EggEncoder.UnitTests/Codecs/Wav/WavReaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,58 @@ public void Open_24Bit_Mono_Should_Sign_Extend_Negative_Samples()
}
}

[Fact]
public void Open_8Bit_Mono_Should_Convert_Unsigned_Bytes_To_Signed_Samples()
{
var filePath = Path.GetTempFileName();
try
{
var unsignedBytes = new[] { 128, 255, 0, 64 };
WavFileBuilder.Create(filePath, channels: 1, sampleRate: 8000, bitsPerSample: 8, unsignedBytes);

using var wavReader = WavReader.Open(filePath);
wavReader.BitsPerSample.Should().Be(8);

var buffer = new int[unsignedBytes.Length];
var framesRead = wavReader.ReadInterleavedSamples(buffer, maxSamplesPerChannel: unsignedBytes.Length);

framesRead.Should().Be(unsignedBytes.Length);
buffer.Should().Equal(0, 127, -128, -64);
}
finally
{
File.Delete(filePath);
}
}

[Fact]
public void Open_32BitFloat_Stereo_Should_Scale_To_Full_Int32_Range()
{
var filePath = Path.GetTempFileName();
try
{
var floatSamples = new[] { 0f, 1f, -1f, 0.5f };
WavFileBuilder.CreateFloat32(filePath, channels: 2, sampleRate: 44100, floatSamples);

using var wavReader = WavReader.Open(filePath);
wavReader.BitsPerSample.Should().Be(32);
wavReader.Channels.Should().Be(2);

var buffer = new int[floatSamples.Length];
var framesRead = wavReader.ReadInterleavedSamples(buffer, maxSamplesPerChannel: 2);

framesRead.Should().Be(2);
buffer[0].Should().Be(0);
buffer[1].Should().Be(int.MaxValue);
buffer[2].Should().Be(-int.MaxValue);
buffer[3].Should().Be(int.MaxValue / 2);
}
finally
{
File.Delete(filePath);
}
}

[Fact]
public void ReadInterleavedSamples_Should_Return_Zero_At_End_Of_Stream()
{
Expand Down Expand Up @@ -85,7 +137,7 @@ public void Open_With_UnsupportedBitsPerSample_Should_Throw()
var filePath = Path.GetTempFileName();
try
{
WavFileBuilder.Create(filePath, channels: 1, sampleRate: 44100, bitsPerSample: 8, [0, 1]);
WavFileBuilder.Create(filePath, channels: 1, sampleRate: 44100, bitsPerSample: 12, [0, 1]);

var act = () => WavReader.Open(filePath).Dispose();
act.Should().ThrowExactly<NotSupportedException>();
Expand Down
Loading
Loading