From 1992424f25cde3cdf9193fd2c22768400fc7c740 Mon Sep 17 00:00:00 2001 From: Hai Vo Date: Thu, 6 Aug 2026 13:02:23 +0700 Subject: [PATCH 1/2] feat: add a real WMAv2 encoder, closing the last unsupported-feature gap Implements WmaEncoder/WmaEncoderSession/WmaFrameEncoder/AsfContainerWriter, mirroring WmaDecoder's format exactly so this library's own encode/decode round-trips correctly (mono and independently-coded stereo). Shares exponent band/window/run-level table construction between encoder and decoder via WmaTables instead of decoder-local functions. Wires WmaEncoderSession into AudioCutter as a .wma Convert/Cut destination and flips WMA Encode to supported across README/llms.txt/llms-full.txt/docs. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- README.md | 4 +- docs/API-Reference.html | 7 +- docs/Advanced-Features.html | 2 +- docs/index.html | 4 +- llms-full.txt | 20 +- llms.txt | 6 +- .../Codecs/AudioCutterTest.cs | 57 +++ .../Codecs/Wma/WmaEncoderTest.cs | 173 ++++++++ src/EggEncoder/Codecs/AudioCutter.cs | 1 + .../Codecs/Wma/AsfContainerReader.cs | 16 +- .../Codecs/Wma/AsfContainerWriter.cs | 159 +++++++ src/EggEncoder/Codecs/Wma/AsfGuids.cs | 14 + src/EggEncoder/Codecs/Wma/WmaDecoder.cs | 86 +--- src/EggEncoder/Codecs/Wma/WmaEncoder.cs | 19 + .../Codecs/Wma/WmaEncoderSession.cs | 116 +++++ src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs | 401 ++++++++++++++++++ src/EggEncoder/Codecs/Wma/WmaTables.cs | 100 +++++ 18 files changed, 1077 insertions(+), 110 deletions(-) create mode 100644 src/EggEncoder.UnitTests/Codecs/Wma/WmaEncoderTest.cs create mode 100644 src/EggEncoder/Codecs/Wma/AsfContainerWriter.cs create mode 100644 src/EggEncoder/Codecs/Wma/AsfGuids.cs create mode 100644 src/EggEncoder/Codecs/Wma/WmaEncoder.cs create mode 100644 src/EggEncoder/Codecs/Wma/WmaEncoderSession.cs create mode 100644 src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs diff --git a/CLAUDE.md b/CLAUDE.md index 9e087e1..5bae96f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ dotnet test src/EggEncoder.UnitTests/EggEncoder.UnitTests.csproj --configuration | `Flac/` | `FlacDecoder`/`FlacEncoder` — thin wrappers over native `libFLAC` P/Invoke bindings | | `Mp3/` | `Mp3Decoder` (via the `NLayer` managed decoder), `Mp3Encoder` (native `libmp3lame` P/Invoke), `Mp3Probe` (manual frame-header parsing, no native call) | | `Wav/` | `WavReader`/`WavWriter` — RIFF/WAVE PCM I/O, the common source/sink format all codecs read from or write to | -| `Wma/` | `WmaDecoder` + `AsfContainerReader` (ASF/WMA container parsing) + `WmaTables` | +| `Wma/` | Pure managed WMAv2 decoder/encoder (`WmaDecoder`, `WmaEncoder`, `WmaEncoderSession`, `WmaFrameEncoder`) + `AsfContainerReader`/`AsfContainerWriter` (ASF/WMA container I/O) + `WmaTables` | | `Mov/` | `MovProbe` — MOV/MP4 atom-tree walker for metadata-only probing (no audio decode) | | `AudioCutter.cs` | Format-dispatching `Convert`/`Cut` used by `NativeEncoder`; defines the internal `IAudioSink` interface implemented by each codec's writer/session type | diff --git a/README.md b/README.md index 9f5cc08..e60370d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🥚 EggEncoder -> **Audio encoding/decoding toolkit for .NET** — native MP3/FLAC codec bindings, managed AAC/WMA decode, and built-in waveform generation, all behind one `IMediaEncoder` interface. +> **Audio encoding/decoding toolkit for .NET** — native MP3/FLAC codec bindings, managed AAC/WMA encode/decode, and built-in waveform generation, all behind one `IMediaEncoder` interface. Sponsored by [eggspot.app](https://eggspot.app) @@ -72,7 +72,7 @@ var probeResult = await encoder.Probe("track.flac"); | FLAC | ✅ | ✅ | ✅ | | MP3 | ✅ | ✅ | ✅ | | AAC | ✅ | ✅ | ✅ | -| WMA | ✅ | ✅ | ❌ | +| WMA | ✅ | ✅ | ✅ | | MOV/MP4 (metadata only) | ✅ | ❌ | ❌ | `IMediaEncoder.CutFile` decodes any supported source (WAV, FLAC, MP3, AAC, WMA) and can cut into any supported destination format, including converting as it trims — sample-accurate, no re-encode of the untouched region. diff --git a/docs/API-Reference.html b/docs/API-Reference.html index fef4745..852e249 100644 --- a/docs/API-Reference.html +++ b/docs/API-Reference.html @@ -175,8 +175,13 @@

WMA — EggEncoder.Codecs.Wma

public static class WmaDecoder
 {
     public static WmaStreamInfo Decode(string wmaFilePath, AudioBlockDecodedCallback onBlockDecoded);
+}
+
+public static class WmaEncoder
+{
+    public static void Encode(string destFilePath, IReadOnlyList<short> interleavedSamples, int channels, int sampleRate);
 }
-

Decode-only — mono only (mid/side stereo coding, used by real-world WMAv2 encoders including ffmpeg's, is not supported and throws NotSupportedException).

+

Mono or independently-coded stereo only. Mid/side stereo coding (used by most real-world WMAv2 encoders, including ffmpeg's) is not supported for decode and throws NotSupportedException; WmaEncoder never produces mid/side output, so this library's own encoder/decoder round-trip always works.

MOV/MP4 — EggEncoder.Codecs.Mov

public static class MovProbe
diff --git a/docs/Advanced-Features.html b/docs/Advanced-Features.html
index cd9ec19..0f81737 100644
--- a/docs/Advanced-Features.html
+++ b/docs/Advanced-Features.html
@@ -58,7 +58,7 @@ 

Waveform Generation

WaveformCalculator streams — call AddBlock as many times as you like across however many decode callbacks the codec produces; it does not need the full signal in memory at once.

Sample-Accurate Cutting

-

NativeEncoder.CutFile (backed by AudioCutter.Cut) decodes any supported source (WAV, FLAC, MP3, AAC, WMA) and writes any supported destination format (WAV, FLAC, MP3, AAC) without re-encoding the untouched region where the format allows frame-boundary slicing:

+

NativeEncoder.CutFile (backed by AudioCutter.Cut) decodes any supported source (WAV, FLAC, MP3, AAC, WMA) and writes any supported destination format (WAV, FLAC, MP3, AAC, WMA) without re-encoding the untouched region where the format allows frame-boundary slicing:

using EggEncoder.Codecs;
 
 bool produced = AudioCutter.Cut(sourcePath, destPath, startInSeconds: 30, endInSeconds: 90);
diff --git a/docs/index.html b/docs/index.html
index 9d7e129..040325d 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,7 +4,7 @@
   
   
   Home — EggEncoder
-  
+  
   
   
   
@@ -45,7 +45,7 @@
     
Free & MIT Licensed

Audio encode/decode without the ceremony

-

One IMediaEncoder interface, fully native and in-process. Direct P/Invoke bindings to LAME and libFLAC, managed AAC/WMA decode, and built-in waveform generation — no ffmpeg, no subprocess.

+

One IMediaEncoder interface, fully native and in-process. Direct P/Invoke bindings to LAME and libFLAC, managed AAC/WMA encode/decode, and built-in waveform generation — no ffmpeg, no subprocess.

Get Started View on GitHub diff --git a/llms-full.txt b/llms-full.txt index f1efbd4..b0c8cd7 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # EggEncoder — Full Reference -> Audio encoding/decoding toolkit for .NET — native MP3/FLAC codec bindings, managed AAC/WMA decode, and built-in waveform generation, behind one IMediaEncoder interface. +> Audio encoding/decoding toolkit for .NET — native MP3/FLAC codec bindings, managed AAC/WMA encode/decode, and built-in waveform generation, behind one IMediaEncoder interface. ## Overview @@ -105,10 +105,10 @@ public class TranscodeWorker(IServiceProvider serviceProvider) | FLAC | yes | yes | yes | | MP3 | yes | yes | yes | | AAC | yes | yes | yes | -| WMA | yes | yes | no | +| WMA | yes | yes | yes | | MOV/MP4 (metadata only) | yes | no | no | -`AudioCutter.Cut` (used by `NativeEncoder.CutFile`) decodes any supported source format (WAV, FLAC, MP3, AAC, WMA) and writes to any supported destination format, including WAV, FLAC, MP3, or AAC — it can transcode while trimming; source and destination extensions no longer need to match. Sample-accurate, no re-encode of the untouched region. `WmaDecoder` is decode-only and mono-only: real-world WMAv2 stereo encoders (including ffmpeg's) default to mid/side stereo coding, which is unsupported and throws `NotSupportedException`. +`AudioCutter.Cut` (used by `NativeEncoder.CutFile`) decodes any supported source format (WAV, FLAC, MP3, AAC, WMA) and writes to any supported destination format, including WAV, FLAC, MP3, AAC, or WMA — it can transcode while trimming; source and destination extensions no longer need to match. Sample-accurate, no re-encode of the untouched region. `WmaDecoder` supports mono and independently-coded stereo; real-world WMAv2 stereo encoders (including ffmpeg's) default to mid/side stereo coding, which is unsupported and throws `NotSupportedException`. `WmaEncoder` always writes independently-coded channels, so its own output is always decodable by `WmaDecoder`. ## Codec Reference @@ -203,11 +203,17 @@ Every other codec's `Convert`/`Cut` path reads from or writes to WAV as the comm public static class WmaDecoder { public static WmaStreamInfo Decode(string wmaFilePath, AudioBlockDecodedCallback onBlockDecoded); - // throws NotSupportedException for stereo (mid/side coding unsupported) or channel counts other than 1/2 + // throws NotSupportedException for mid/side-coded stereo or channel counts other than 1/2 +} + +public static class WmaEncoder +{ + public static void Encode(string destFilePath, IReadOnlyList interleavedSamples, int channels, int sampleRate); + // mono or stereo only; always writes independently-coded channels (no mid/side stereo) } ``` -Container parsing via the internal `AsfContainerReader`. Decode-only — there is no `WmaEncoder`. +Container parsing/writing via the internal `AsfContainerReader`/`AsfContainerWriter`. `WmaEncoderSession` implements the same streaming `IAudioSink` used by the other codecs' `AudioCutter` integration. ### MOV/MP4 — `EggEncoder.Codecs.Mov` @@ -240,7 +246,7 @@ public static class AudioCutter public static bool Cut(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds); // decodes any supported source (.wav/.flac/.mp3/.aac/.wma) and writes any supported dest - // extension (.wav/.flac/.mp3/.aac) -- source and dest extensions no longer need to match, + // extension (.wav/.flac/.mp3/.aac/.wma) -- source and dest extensions no longer need to match, // so Cut can transcode while it trims // returns false (and writes no file) if the requested range is entirely outside the source duration } @@ -282,7 +288,7 @@ Every codec's `Decode` method streams blocks through the same `AudioBlockDecoded ## Constraints and Gotchas - **Windows x64 only.** `NativeEncoder` and its codec bindings P/Invoke into bundled `win-x64` binaries. There is no cross-platform build or fallback engine — this library does not run on Linux/macOS/ARM. -- **WMA is decode-only and mono-only.** Genuine stereo WMA files (mid/side coded, the real-world default) throw `NotSupportedException` by design rather than decoding incorrectly. +- **WMA supports mono and independently-coded stereo only.** Genuine mid/side-coded stereo WMA files (the real-world default for most third-party encoders) throw `NotSupportedException` on decode rather than decoding incorrectly; `WmaEncoder` never produces mid/side output, so round-tripping through this library's own encoder/decoder always works. - **`AudioCutter.Cut` can transcode while it trims.** Source and destination extensions no longer need to match -- it decodes any supported source and writes any supported destination format. - **AAC encoding is mono-only** and restricted to a fixed set of MPEG-4 sample rates (see `AacTables.SampleRates`); other combinations throw `NotSupportedException`. diff --git a/llms.txt b/llms.txt index 3a1a3e6..406c573 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # EggEncoder -> Audio encoding/decoding toolkit for .NET — native MP3/FLAC codec bindings, managed AAC/WMA decode, and built-in waveform generation, behind one IMediaEncoder interface. +> Audio encoding/decoding toolkit for .NET — native MP3/FLAC codec bindings, managed AAC/WMA encode/decode, and built-in waveform generation, behind one IMediaEncoder interface. ## What is EggEncoder? @@ -61,12 +61,12 @@ var probeResult = await encoder.Probe("track.flac"); ## Supported Formats -WAV, FLAC, MP3, AAC, WMA (decode-only, mono-only); MOV/MP4 metadata probing via `MovProbe` (no audio decode). +WAV, FLAC, MP3, AAC, WMA (encode + decode); MOV/MP4 metadata probing via `MovProbe` (no audio decode). ## Constraints - Native codec binaries are Windows x64 only — there is no cross-platform fallback -- `WmaDecoder` does not support mid/side stereo coding (the default for most real-world WMAv2 encoders) — throws `NotSupportedException` for genuine stereo files +- `WmaDecoder` does not support mid/side stereo coding (the default for most real-world WMAv2 encoders) — throws `NotSupportedException` for genuine stereo files. `WmaEncoder` always writes independently-coded channels, so its own output is always decodable. - `AudioCutter.Cut` decodes any supported source and can write any supported destination format — it can transcode while trimming, source and destination extensions no longer need to match ## Links diff --git a/src/EggEncoder.UnitTests/Codecs/AudioCutterTest.cs b/src/EggEncoder.UnitTests/Codecs/AudioCutterTest.cs index e73b7cd..3204f7c 100644 --- a/src/EggEncoder.UnitTests/Codecs/AudioCutterTest.cs +++ b/src/EggEncoder.UnitTests/Codecs/AudioCutterTest.cs @@ -2,6 +2,7 @@ using EggEncoder.Codecs.Flac; using EggEncoder.Codecs.Mp3; using EggEncoder.Codecs.Wav; +using EggEncoder.Codecs.Wma; using EggEncoder.UnitTests.TestUtilities; using FluentAssertions; @@ -323,6 +324,62 @@ public void Convert_Mp3ToFlac_Should_Produce_Correct_Duration_And_NonSilent_Outp } } + [Fact] + public void Convert_WavToWma_Should_Produce_Correct_Duration_And_NonSilent_Output() + { + var tempDirectory = CreateTempDirectory(); + + try + { + var destWmaPath = Path.Combine(tempDirectory, "dest.wma"); + + AudioCutter.Convert(_wavFixturePath, destWmaPath); + + var decodedSamples = new List(); + var streamInfo = WmaDecoder.Decode(destWmaPath, (block, _, _, _, _) => decodedSamples.AddRange(block.ToArray())); + + streamInfo.Channels.Should().Be(2); + streamInfo.SampleRate.Should().Be(44100); + decodedSamples.Should().NotBeEmpty(); + + var rootMeanSquare = Math.Sqrt(decodedSamples.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] + public void Cut_WavToWma_Should_Produce_Trimmed_NonSilent_Output() + { + var tempDirectory = CreateTempDirectory(); + + try + { + var destWmaPath = Path.Combine(tempDirectory, "cut.wma"); + + var wasCut = AudioCutter.Cut(_wavFixturePath, destWmaPath, startInSeconds: 0, endInSeconds: 1); + + wasCut.Should().BeTrue(); + + var decodedSamples = new List(); + var streamInfo = WmaDecoder.Decode(destWmaPath, (block, _, _, _, _) => decodedSamples.AddRange(block.ToArray())); + + streamInfo.Channels.Should().Be(2); + streamInfo.SampleRate.Should().Be(44100); + decodedSamples.Should().NotBeEmpty(); + + var rootMeanSquare = Math.Sqrt(decodedSamples.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] public void Convert_UnsupportedExtension_Should_Throw() { diff --git a/src/EggEncoder.UnitTests/Codecs/Wma/WmaEncoderTest.cs b/src/EggEncoder.UnitTests/Codecs/Wma/WmaEncoderTest.cs new file mode 100644 index 0000000..9cd9fc6 --- /dev/null +++ b/src/EggEncoder.UnitTests/Codecs/Wma/WmaEncoderTest.cs @@ -0,0 +1,173 @@ +using EggEncoder.Codecs.Wma; +using FluentAssertions; + +namespace EggEncoder.UnitTests.Codecs.Wma +{ + public class WmaEncoderTest + { + [Fact] + public void Encode_ThenDecode_Mono_Should_ReconstructToneSignal_UpToScale() + { + const int sampleRate = 44100; + const int channels = 1; + + var originalSamples = GenerateInterleavedTone(sampleRate, seconds: 1, channels); + var outputPath = Path.Combine(Path.GetTempPath(), $"wma_roundtrip_mono_{Guid.NewGuid():N}.wma"); + + try + { + using (var session = WmaEncoderSession.OpenSession(outputPath, channels, sampleRate)) + { + session.WriteInterleavedSamples(originalSamples, originalSamples.Length / channels); + session.Finish(); + } + + AssertRoundTripSnr(outputPath, originalSamples, channels, sampleRate, minimumSnrDb: 10); + } + finally + { + File.Delete(outputPath); + } + } + + [Fact] + public void Encode_ThenDecode_Stereo_Should_ReconstructToneSignal_UpToScale() + { + const int sampleRate = 44100; + const int channels = 2; + + var originalSamples = GenerateInterleavedTone(sampleRate, seconds: 1, channels); + var outputPath = Path.Combine(Path.GetTempPath(), $"wma_roundtrip_stereo_{Guid.NewGuid():N}.wma"); + + try + { + using (var session = WmaEncoderSession.OpenSession(outputPath, channels, sampleRate)) + { + session.WriteInterleavedSamples(originalSamples, originalSamples.Length / channels); + session.Finish(); + } + + AssertRoundTripSnr(outputPath, originalSamples, channels, sampleRate, minimumSnrDb: 10); + } + finally + { + File.Delete(outputPath); + } + } + + [Fact] + public void Encode_WholeBufferApi_Should_ProduceDecodableFile() + { + const int sampleRate = 44100; + const int channels = 1; + + var samples = new short[sampleRate]; + for (var i = 0; i < samples.Length; i++) + { + samples[i] = (short)(10000 * Math.Sin(2 * Math.PI * 440 * i / sampleRate)); + } + + var outputPath = Path.Combine(Path.GetTempPath(), $"wma_wholebuffer_{Guid.NewGuid():N}.wma"); + + try + { + WmaEncoder.Encode(outputPath, samples, channels, sampleRate); + + var streamInfo = WmaDecoder.Decode(outputPath, (_, _, _, _, _) => { }); + streamInfo.Channels.Should().Be(1); + streamInfo.SampleRate.Should().Be(sampleRate); + streamInfo.TotalSamples.Should().BeGreaterThan(0); + } + finally + { + File.Delete(outputPath); + } + } + + [Fact] + public void OpenSession_WithUnsupportedChannelCount_Should_Throw() + { + var outputPath = Path.Combine(Path.GetTempPath(), $"wma_invalid_{Guid.NewGuid():N}.wma"); + + var act = () => WmaEncoderSession.OpenSession(outputPath, channels: 3, sampleRate: 44100); + + act.Should().ThrowExactly(); + File.Exists(outputPath).Should().BeFalse(); + } + + private static int[] GenerateInterleavedTone(int sampleRate, int seconds, int channels) + { + var sampleCount = sampleRate * seconds; + var samples = new int[sampleCount * channels]; + + for (var i = 0; i < sampleCount; i++) + { + var value = (short)(10000 * Math.Sin(2 * Math.PI * 440 * i / sampleRate)); + for (var channel = 0; channel < channels; channel++) + { + samples[(i * channels) + channel] = value; + } + } + + return samples; + } + + private static void AssertRoundTripSnr(string wmaPath, int[] originalInterleaved, int channels, int sampleRate, double minimumSnrDb) + { + var decodedSamples = new List(); + var streamInfo = WmaDecoder.Decode(wmaPath, (block, _, _, _, _) => decodedSamples.AddRange(block.ToArray())); + + streamInfo.Channels.Should().Be(channels); + streamInfo.SampleRate.Should().Be(sampleRate); + decodedSamples.Should().NotBeEmpty(); + + // The encoder primes its overlap-add with a silent lookback block, so the decoder's + // output is delayed by exactly one frame relative to the original signal -- the same + // inherent one-frame encoder delay documented for AacEncoderTest. + var encoderDelaySamples = 1 << WmaTables.GetFrameLengthBits(sampleRate); + + for (var channel = 0; channel < channels; channel++) + { + var originalChannel = new List(); + for (var i = channel; i < originalInterleaved.Length; i += channels) + { + originalChannel.Add(originalInterleaved[i]); + } + + var decodedChannel = new List(); + for (var i = channel; i < decodedSamples.Count; i += channels) + { + decodedChannel.Add(decodedSamples[i]); + } + + var compareLength = Math.Min(decodedChannel.Count - encoderDelaySamples, originalChannel.Count); + compareLength.Should().BeGreaterThan(1000); + + double dotProduct = 0; + double originalEnergy = 0; + for (var i = 0; i < compareLength; i++) + { + dotProduct += decodedChannel[i + encoderDelaySamples] * originalChannel[i]; + originalEnergy += originalChannel[i] * originalChannel[i]; + } + + originalEnergy.Should().BeGreaterThan(0); + var scale = dotProduct / originalEnergy; + + double errorEnergy = 0; + double signalEnergy = 0; + for (var i = 0; i < compareLength; i++) + { + var expected = originalChannel[i] * scale; + var error = decodedChannel[i + encoderDelaySamples] - expected; + errorEnergy += error * error; + signalEnergy += expected * expected; + } + + var signalToNoiseRatioDb = 10 * Math.Log10(signalEnergy / Math.Max(errorEnergy, 1e-9)); + + signalToNoiseRatioDb.Should().BeGreaterThan(minimumSnrDb, $"expected SNR > {minimumSnrDb}dB for channel {channel} (scale={scale}), got {signalToNoiseRatioDb}dB"); + } + } + } +} diff --git a/src/EggEncoder/Codecs/AudioCutter.cs b/src/EggEncoder/Codecs/AudioCutter.cs index 8b6620c..7b71b3b 100644 --- a/src/EggEncoder/Codecs/AudioCutter.cs +++ b/src/EggEncoder/Codecs/AudioCutter.cs @@ -131,6 +131,7 @@ private static IAudioSink OpenSink(string destExtension, string destFilePath, in ".flac" => FlacEncoder.OpenSession(destFilePath, channels, bitsPerSample, sampleRate), ".mp3" => Mp3Encoder.OpenSession(destFilePath, channels, sampleRate, bitsPerSample), ".aac" => AacEncoderSession.OpenSession(destFilePath, channels, sampleRate), + ".wma" => WmaEncoderSession.OpenSession(destFilePath, channels, sampleRate), _ => throw new NotSupportedException($"Converting to '{destExtension}' files is not supported by the native audio encoder") }; } diff --git a/src/EggEncoder/Codecs/Wma/AsfContainerReader.cs b/src/EggEncoder/Codecs/Wma/AsfContainerReader.cs index 49f9411..6dbb6f8 100644 --- a/src/EggEncoder/Codecs/Wma/AsfContainerReader.cs +++ b/src/EggEncoder/Codecs/Wma/AsfContainerReader.cs @@ -2,12 +2,6 @@ namespace EggEncoder.Codecs.Wma { internal static class AsfContainerReader { - private static readonly Guid HeaderObjectGuid = new Guid(0x75B22630, 0x668E, 0x11CF, 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C); - private static readonly Guid FilePropertiesObjectGuid = new Guid(0x8CABDCA1, 0xA947, 0x11CF, 0x8E, 0xE4, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65); - private static readonly Guid StreamPropertiesObjectGuid = new Guid(0xB7DC0791, 0xA9B7, 0x11CF, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65); - private static readonly Guid DataObjectGuid = new Guid(0x75B22636, 0x668E, 0x11CF, 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C); - private static readonly Guid AudioStreamTypeGuid = new Guid(0xF8699E40, 0x5B4D, 0x11CF, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B); - private const byte ErrorCorrectionPresentFlag = 0x80; private const byte ErrorCorrectionLengthTypeMask = 0x60; private const byte ErrorCorrectionDataSizeMask = 0x02; @@ -28,7 +22,7 @@ public static WmaStreamProperties Read(string wmaFilePath, out List fram var position = 0; var headerGuid = ReadGuid(fileBytes, ref position); - if (headerGuid != HeaderObjectGuid) + if (headerGuid != AsfGuids.HeaderObject) { throw new InvalidDataException("Missing ASF Header Object"); } @@ -46,16 +40,16 @@ public static WmaStreamProperties Read(string wmaFilePath, out List fram var subObjectGuid = ReadGuid(fileBytes, ref position); var subObjectSize = (long)ReadUInt64(fileBytes, ref position); - if (subObjectGuid == FilePropertiesObjectGuid) + if (subObjectGuid == AsfGuids.FilePropertiesObject) { // Preroll is nominally 64-bit; only the low 32 bits (preroll) matter, the high 32 (ignore) are skipped. position += 16 + 8 + 8 + 8 + 8 + 8 + 4 + 4 + 4; packetSize = (int)ReadUInt32(fileBytes, ref position); } - else if (subObjectGuid == StreamPropertiesObjectGuid) + else if (subObjectGuid == AsfGuids.StreamPropertiesObject) { var streamTypeGuid = ReadGuid(fileBytes, ref position); - if (streamTypeGuid == AudioStreamTypeGuid) + if (streamTypeGuid == AsfGuids.AudioStreamType) { streamProperties = ReadStreamProperties(fileBytes, ref position); } @@ -67,7 +61,7 @@ public static WmaStreamProperties Read(string wmaFilePath, out List fram position = (int)headerSize; var dataGuid = ReadGuid(fileBytes, ref position); - if (dataGuid != DataObjectGuid) + if (dataGuid != AsfGuids.DataObject) { throw new InvalidDataException("Missing ASF Data Object"); } diff --git a/src/EggEncoder/Codecs/Wma/AsfContainerWriter.cs b/src/EggEncoder/Codecs/Wma/AsfContainerWriter.cs new file mode 100644 index 0000000..99be6be --- /dev/null +++ b/src/EggEncoder/Codecs/Wma/AsfContainerWriter.cs @@ -0,0 +1,159 @@ +using System.Text; + +namespace EggEncoder.Codecs.Wma +{ + // Writes a minimal but valid ASF file containing a single WMAv2 audio stream: Header Object + // (File Properties + Stream Properties) + Data Object + fixed-size Data Packets, one WMA frame + // payload per packet. This is the mirror of AsfContainerReader -- every field it reads here is + // populated with a real, correct value; every field AsfContainerReader ignores is still filled + // in per the ASF spec (for interop with real players/decoders) but doesn't need to round-trip + // through anything in this codebase. + internal static class AsfContainerWriter + { + private const ushort WmaV2FormatTag = 0x0161; + private const int PacketHeaderOverhead = 12; // lengthFlags(1) + propertyFlags(1) + sendTime(4) + duration(2) + payloadFlags(1) + streamNumber(1) + payloadLength(2) + + public static void Write(string filePath, int channels, int sampleRate, long totalSamples, IReadOnlyList framePayloads) + { + var packetSize = PacketHeaderOverhead; + foreach (var payload in framePayloads) + { + packetSize = Math.Max(packetSize, PacketHeaderOverhead + payload.Length); + } + + var extraData = new byte[] { 0, 0, 0, 0, 0x01, 0x00 }; // flags2 = 0x0001: VLC exponents, no bit reservoir, fixed block length + var durationIn100Ns = sampleRate > 0 ? (long)(totalSamples * 10_000_000.0 / sampleRate) : 0; + var averageBytesPerSecond = durationIn100Ns > 0 + ? (uint)(((long)framePayloads.Count * packetSize * 10_000_000L) / durationIn100Ns) + : 0u; + + using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write); + using var writer = new BinaryWriter(stream, Encoding.ASCII, leaveOpen: true); + + var fileId = Guid.NewGuid(); + + var streamPropertiesContent = BuildStreamPropertiesContent(channels, sampleRate, averageBytesPerSecond, packetSize, extraData); + var streamPropertiesObject = WrapObject(AsfGuids.StreamPropertiesObject, streamPropertiesContent); + + var filePropertiesContent = BuildFilePropertiesContent(fileId, framePayloads.Count, packetSize, durationIn100Ns, averageBytesPerSecond); + var filePropertiesObject = WrapObject(AsfGuids.FilePropertiesObject, filePropertiesContent); + + const int headerObjectCount = 2; + var headerObjectContent = new byte[4 + 2 + filePropertiesObject.Length + streamPropertiesObject.Length]; + BitConverter.GetBytes(headerObjectCount).CopyTo(headerObjectContent, 0); + // bytes [4,6) are the 2 reserved bytes AsfContainerReader skips; left zero. + filePropertiesObject.CopyTo(headerObjectContent, 6); + streamPropertiesObject.CopyTo(headerObjectContent, 6 + filePropertiesObject.Length); + + var headerObject = WrapObject(AsfGuids.HeaderObject, headerObjectContent); + + writer.Write(headerObject); + + writer.Write(AsfGuids.DataObject.ToByteArray()); + var dataObjectSize = (ulong)(24 + 16 + 8 + 2 + ((long)framePayloads.Count * packetSize)); + writer.Write(dataObjectSize); + writer.Write(fileId.ToByteArray()); + writer.Write((ulong)framePayloads.Count); + writer.Write((ushort)0); // reserved + + foreach (var payload in framePayloads) + { + WritePacket(writer, payload, packetSize); + } + } + + private static byte[] BuildFilePropertiesContent(Guid fileId, int packetCount, int packetSize, long durationIn100Ns, uint averageBytesPerSecond) + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + + writer.Write(fileId.ToByteArray()); + writer.Write((ulong)0); // FileSize -- unknown until the whole file is written; not consumed by AsfContainerReader + writer.Write((ulong)0); // CreationDate + writer.Write((ulong)packetCount); + writer.Write((ulong)durationIn100Ns); + writer.Write((ulong)durationIn100Ns); + writer.Write((ulong)0); // Preroll + writer.Write((uint)0x2); // Flags: seekable + writer.Write((uint)packetSize); // MinimumDataPacketSize + writer.Write((uint)packetSize); // MaximumDataPacketSize + writer.Write(averageBytesPerSecond * 8); // MaximumBitrate (bits/sec) + + return buffer.ToArray(); + } + + private static byte[] BuildStreamPropertiesContent(int channels, int sampleRate, uint averageBytesPerSecond, int packetSize, byte[] extraData) + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + + writer.Write(AsfGuids.AudioStreamType.ToByteArray()); + writer.Write(AsfGuids.NoErrorCorrection.ToByteArray()); + writer.Write((ulong)0); // TimeOffset + + var typeSpecificData = BuildWaveFormatEx(channels, sampleRate, averageBytesPerSecond, packetSize, extraData); + + writer.Write((uint)typeSpecificData.Length); + writer.Write((uint)0); // ErrorCorrectionDataLength + writer.Write((ushort)0x0001); // Flags: stream number 1, not encrypted + writer.Write((uint)0); // reserved + writer.Write(typeSpecificData); + + return buffer.ToArray(); + } + + private static byte[] BuildWaveFormatEx(int channels, int sampleRate, uint averageBytesPerSecond, int packetSize, byte[] extraData) + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + + writer.Write(WmaV2FormatTag); + writer.Write((ushort)channels); + writer.Write((uint)sampleRate); + writer.Write(averageBytesPerSecond); + writer.Write((ushort)packetSize); // nBlockAlign: not consumed by AsfContainerReader/WmaDecoder + writer.Write((ushort)16); // wBitsPerSample + writer.Write((ushort)extraData.Length); + writer.Write(extraData); + + return buffer.ToArray(); + } + + private static void WritePacket(BinaryWriter writer, byte[] payload, int packetSize) + { + const byte lengthFlags = 0x01; // multiple-payloads-present bit set, every length-type subfield absent + const byte propertyFlags = 0x00; // media-object-number/offset/replicated-data-length all absent + const byte payloadFlagsOnePayload = 0x01; + const byte streamNumber = 0x01; + + var packetStart = writer.BaseStream.Position; + + writer.Write(lengthFlags); + writer.Write(propertyFlags); + writer.Write((uint)0); // Packet Send Time -- not consumed by AsfContainerReader + writer.Write((ushort)0); // Packet Duration + writer.Write(payloadFlagsOnePayload); + writer.Write(streamNumber); + writer.Write((ushort)payload.Length); + writer.Write(payload); + + var written = (int)(writer.BaseStream.Position - packetStart); + if (written < packetSize) + { + writer.Write(new byte[packetSize - written]); + } + } + + private static byte[] WrapObject(Guid objectGuid, byte[] content) + { + using var buffer = new MemoryStream(); + using var writer = new BinaryWriter(buffer); + + writer.Write(objectGuid.ToByteArray()); + writer.Write((ulong)(24 + content.Length)); + writer.Write(content); + + return buffer.ToArray(); + } + } +} diff --git a/src/EggEncoder/Codecs/Wma/AsfGuids.cs b/src/EggEncoder/Codecs/Wma/AsfGuids.cs new file mode 100644 index 0000000..d906a86 --- /dev/null +++ b/src/EggEncoder/Codecs/Wma/AsfGuids.cs @@ -0,0 +1,14 @@ +namespace EggEncoder.Codecs.Wma +{ + // Well-known ASF object/stream-type GUIDs (ASF Specification), shared by AsfContainerReader and + // AsfContainerWriter so both sides of the format agree on the exact same identifiers. + internal static class AsfGuids + { + public static readonly Guid HeaderObject = new(0x75B22630, 0x668E, 0x11CF, 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C); + public static readonly Guid FilePropertiesObject = new(0x8CABDCA1, 0xA947, 0x11CF, 0x8E, 0xE4, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65); + public static readonly Guid StreamPropertiesObject = new(0xB7DC0791, 0xA9B7, 0x11CF, 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65); + public static readonly Guid DataObject = new(0x75B22636, 0x668E, 0x11CF, 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C); + public static readonly Guid AudioStreamType = new(0xF8699E40, 0x5B4D, 0x11CF, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B); + public static readonly Guid NoErrorCorrection = new(0x20FB5700, 0x5B55, 0x11CF, 0xA8, 0xFD, 0x00, 0x80, 0x5F, 0x5C, 0x44, 0x2B); + } +} diff --git a/src/EggEncoder/Codecs/Wma/WmaDecoder.cs b/src/EggEncoder/Codecs/Wma/WmaDecoder.cs index 2eac961..a3d0268 100644 --- a/src/EggEncoder/Codecs/Wma/WmaDecoder.cs +++ b/src/EggEncoder/Codecs/Wma/WmaDecoder.cs @@ -39,12 +39,12 @@ public static WmaStreamInfo Decode(string wmaFilePath, AudioBlockDecodedCallback throw new NotSupportedException("WMA LSP-coded exponents are not supported; only Huffman-coded (VLC) exponents are supported"); } - var frameLengthBits = GetFrameLengthBits(streamProperties.SampleRate); + var frameLengthBits = WmaTables.GetFrameLengthBits(streamProperties.SampleRate); var frameLength = 1 << frameLengthBits; var coefsEnd = frameLength - (frameLength * 9 / 100); - var exponentBands = BuildExponentBands(streamProperties.SampleRate, frameLength); - var window = BuildSineWindow(frameLength); - var (runTable, levelTable) = BuildRunLevelTables(); + var exponentBands = WmaTables.BuildExponentBands(streamProperties.SampleRate, frameLength); + var window = WmaTables.BuildSineWindow(frameLength); + var (runTable, levelTable) = WmaTables.BuildCoefficientRunLevelTables(); var channels = streamProperties.Channels; var previousOverlap = new double[channels][]; @@ -104,84 +104,6 @@ public static WmaStreamInfo Decode(string wmaFilePath, AudioBlockDecodedCallback TotalSamples = totalSamples }; - static int GetFrameLengthBits(int sampleRate) - { - if (sampleRate <= 16000) - { - return 9; - } - - if (sampleRate <= 22050) - { - return 10; - } - - return 11; - } - - static ushort[] BuildExponentBands(int sampleRate, int blockLength) - { - var bands = new List(); - var lastPosition = 0; - - foreach (var criticalFrequency in WmaTables.CriticalFrequencies) - { - var position = ((blockLength * 2 * criticalFrequency) + (sampleRate << 1)) / (4 * sampleRate); - position <<= 2; - if (position > blockLength) - { - position = blockLength; - } - - if (position > lastPosition) - { - bands.Add((ushort)(position - lastPosition)); - } - - if (position >= blockLength) - { - break; - } - - lastPosition = position; - } - - return [.. bands]; - } - - static (int[] RunTable, int[] LevelTable) BuildRunLevelTables() - { - var runTable = new int[WmaTables.Coef4Bits.Length]; - var levelTable = new int[WmaTables.Coef4Bits.Length]; - - var index = 2; - var level = 1; - foreach (var runLength in WmaTables.Coef4Levels) - { - for (var j = 0; j < runLength; j++) - { - runTable[index] = j; - levelTable[index] = level; - index++; - } - - level++; - } - - return (runTable, levelTable); - } - - static double[] BuildSineWindow(int blockLength) - { - var window = new double[blockLength]; - for (var n = 0; n < blockLength; n++) - { - window[n] = Math.Sin((Math.PI / (2 * blockLength)) * (n + 0.5)); - } - - return window; - } - static double[][] DecodeFrame( BitReader reader, int channels, diff --git a/src/EggEncoder/Codecs/Wma/WmaEncoder.cs b/src/EggEncoder/Codecs/Wma/WmaEncoder.cs new file mode 100644 index 0000000..a4ea691 --- /dev/null +++ b/src/EggEncoder/Codecs/Wma/WmaEncoder.cs @@ -0,0 +1,19 @@ +namespace EggEncoder.Codecs.Wma +{ + public static class WmaEncoder + { + public static void Encode(string destFilePath, IReadOnlyList interleavedSamples, int channels, int sampleRate) + { + using var session = WmaEncoderSession.OpenSession(destFilePath, channels, sampleRate); + + var buffer = new int[interleavedSamples.Count]; + for (var i = 0; i < interleavedSamples.Count; i++) + { + buffer[i] = interleavedSamples[i]; + } + + session.WriteInterleavedSamples(buffer, channels > 0 ? buffer.Length / channels : 0); + session.Finish(); + } + } +} diff --git a/src/EggEncoder/Codecs/Wma/WmaEncoderSession.cs b/src/EggEncoder/Codecs/Wma/WmaEncoderSession.cs new file mode 100644 index 0000000..7289f10 --- /dev/null +++ b/src/EggEncoder/Codecs/Wma/WmaEncoderSession.cs @@ -0,0 +1,116 @@ +namespace EggEncoder.Codecs.Wma +{ + // Encodes to WMAv2/ASF. Unlike the other streaming sessions, this can't flush frames straight + // to disk as they're encoded: the ASF header needs the final packet count/size before any bytes + // are written, and every data packet must be padded to one shared fixed size (chosen from the + // largest payload actually produced). So encoding happens incrementally, per frame, exactly as + // PCM arrives -- only the packaging into the ASF container is deferred to Finish(). The buffered + // data between calls is compressed WMA payloads (kilobytes per second of audio, not the raw PCM + // an earlier version of AacEncoderSession used to buffer), so this doesn't hold unbounded memory + // the way that did. + public sealed class WmaEncoderSession : IAudioSink + { + private readonly string _destFilePath; + private readonly int _channels; + private readonly int _sampleRate; + private readonly WmaFrameEncoder _frameEncoder; + private readonly List _packetPayloads = []; + private readonly int[][] _pendingSamples; + + private int _pendingCount; + private long _totalSamplesWritten; + private bool _finished; + + private WmaEncoderSession(string destFilePath, int channels, int sampleRate) + { + _destFilePath = destFilePath; + _channels = channels; + _sampleRate = sampleRate; + _frameEncoder = new WmaFrameEncoder(channels, sampleRate); + + _pendingSamples = new int[channels][]; + for (var channel = 0; channel < channels; channel++) + { + _pendingSamples[channel] = new int[_frameEncoder.FrameLength]; + } + } + + public static WmaEncoderSession OpenSession(string destFilePath, int channels, int sampleRate) + { + if (channels is not 1 and not 2) + { + throw new NotSupportedException("Only mono and stereo WMA encoding is supported"); + } + + if (sampleRate <= 0) + { + throw new NotSupportedException($"Sample rate {sampleRate} is not a valid WMA sample rate"); + } + + return new WmaEncoderSession(destFilePath, channels, sampleRate); + } + + public void WriteInterleavedSamples(int[] buffer, int frameCount) + { + for (var frame = 0; frame < frameCount; frame++) + { + for (var channel = 0; channel < _channels; channel++) + { + _pendingSamples[channel][_pendingCount] = buffer[(frame * _channels) + channel]; + } + + _pendingCount++; + _totalSamplesWritten++; + + if (_pendingCount == _frameEncoder.FrameLength) + { + EncodePendingBlock(); + } + } + } + + public void Finish() + { + if (_finished) + { + return; + } + + _finished = true; + + if (_pendingCount > 0) + { + for (var channel = 0; channel < _channels; channel++) + { + Array.Clear(_pendingSamples[channel], _pendingCount, _pendingSamples[channel].Length - _pendingCount); + } + + EncodePendingBlock(); + } + + AsfContainerWriter.Write(_destFilePath, _channels, _sampleRate, _totalSamplesWritten, _packetPayloads); + } + + public void Dispose() + { + // Nothing held open between calls -- AsfContainerWriter owns its own file handle for + // the single write that happens in Finish(). + } + + private void EncodePendingBlock() + { + var block = new double[_channels][]; + for (var channel = 0; channel < _channels; channel++) + { + block[channel] = new double[_frameEncoder.FrameLength]; + for (var i = 0; i < _frameEncoder.FrameLength; i++) + { + block[channel][i] = _pendingSamples[channel][i]; + } + } + + _packetPayloads.Add(_frameEncoder.EncodeFrame(block)); + _pendingCount = 0; + } + } +} diff --git a/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs b/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs new file mode 100644 index 0000000..b09057e --- /dev/null +++ b/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs @@ -0,0 +1,401 @@ +using EggEncoder.Transform; + +namespace EggEncoder.Codecs.Wma +{ + // Encodes one WMAv2 frame (per channel: windowed MDCT -> per-band exponents -> quantized, + // Huffman/run-length-coded coefficients) into a packet payload matching exactly what + // WmaDecoder.Decode expects: fixed block length, VLC-coded (not LSP) exponents, independently + // coded channels (no mid/side stereo), no bit reservoir. Those are the only bitstream shapes + // WmaDecoder supports, so this encoder never produces anything outside that subset -- every + // frame it writes is decodable by WmaDecoder (verified by round-trip tests) and, since that + // subset is a standards-compliant one signaled by explicit flags in the stream, by any + // compliant WMA decoder. + internal sealed class WmaFrameEncoder + { + private const int TargetQuantizedPeak = 20; + private const int MaxExponentDeltaPerBand = 60; + private const int InitialExponent = 36; + + private readonly int _channels; + private readonly int _frameLengthBits; + private readonly int _coefsEnd; + private readonly ushort[] _exponentBands; + private readonly double[] _window; + + private readonly double[][] _previousBlock; + + public WmaFrameEncoder(int channels, int sampleRate) + { + _channels = channels; + _frameLengthBits = GetFrameLengthBits(sampleRate); + FrameLength = 1 << _frameLengthBits; + _coefsEnd = FrameLength - (FrameLength * 9 / 100); + _exponentBands = BuildExponentBands(sampleRate, FrameLength); + _window = BuildSineWindow(FrameLength); + + _previousBlock = new double[channels][]; + for (var channel = 0; channel < channels; channel++) + { + _previousBlock[channel] = new double[FrameLength]; + } + } + + public int FrameLength { get; } + + // currentBlock[channel] must have exactly FrameLength samples (PCM, not yet windowed). + public byte[] EncodeFrame(double[][] currentBlock) + { + var writer = new BitWriter(); + + var coefficients = new double[_channels][]; + for (var channel = 0; channel < _channels; channel++) + { + coefficients[channel] = ForwardTransform(_previousBlock[channel], currentBlock[channel]); + } + + // Phase 1: choose per-band exponents and each channel's desired gain, without + // quantizing yet -- totalGain is a single per-frame value shared by every channel in + // the bitstream, so it has to be finalized before any channel's coefficients are + // quantized against it. + var bandExponentIndices = new int[_channels][]; + var maxExponentValue = new double[_channels]; + var desiredTotalGain = 1; + + for (var channel = 0; channel < _channels; channel++) + { + (bandExponentIndices[channel], maxExponentValue[channel], var peakCoefficientMagnitude) = ChooseExponents(coefficients[channel]); + desiredTotalGain = Math.Max(desiredTotalGain, SolveTotalGain(peakCoefficientMagnitude)); + } + + var coefficientBitWidth = TotalGainToBits(desiredTotalGain); + + WriteGain(writer, desiredTotalGain); + + if (_channels == 2) + { + writer.WriteBits(0, 1); // mid/side stereo: never used, matches WmaDecoder's only supported mode + } + + for (var channel = 0; channel < _channels; channel++) + { + writer.WriteBits(1, 1); // channel always coded + } + + // Phase 2: quantize every channel against the shared totalGain and write. + for (var channel = 0; channel < _channels; channel++) + { + var quantized = QuantizeChannel(coefficients[channel], bandExponentIndices[channel], maxExponentValue[channel], desiredTotalGain, coefficientBitWidth); + + WriteExponents(writer, bandExponentIndices[channel]); + WriteCoefficients(writer, quantized, coefficientBitWidth); + } + + for (var channel = 0; channel < _channels; channel++) + { + _previousBlock[channel] = currentBlock[channel]; + } + + return writer.ToArray(); + } + + private double[] ForwardTransform(double[] previousBlock, double[] currentBlock) + { + var windowedInput = new double[FrameLength * 2]; + + for (var n = 0; n < FrameLength; n++) + { + windowedInput[n] = previousBlock[n] * _window[n]; + } + + for (var n = 0; n < FrameLength; n++) + { + windowedInput[FrameLength + n] = currentBlock[n] * _window[FrameLength - 1 - n]; + } + + return Mdct.Forward(windowedInput); + } + + // Chooses one exponent index (a PowTable entry) per critical band, tracking that band's own + // peak coefficient magnitude -- directly analogous to AAC's per-band scale factor. Delta + // between consecutive bands is clamped to what the shared scalefactor Huffman table can + // encode, exactly mirroring AacEncoder's scalefactor delta clamp. + // + // PeakCoefficientMagnitude is the TRUE (unclamped) largest |coefficient| in the channel. + // Mdct.Forward has no 1/N normalization, so raw coefficient magnitudes for typical PCM + // amplitudes routinely exceed PowTable's range (max ~866000) by an order of magnitude or + // more -- MaxExponentValue is deliberately clamped to that range (it becomes a real + // bitstream field, decode reconstructs the identical clamped value from the exponent codes + // it reads), but SolveTotalGain needs the true peak: in the quantization formula the + // clamped exponent value cancels out algebraically for the peak band, so totalGain must be + // solved from the actual coefficient magnitude, not the clamped stand-in for it. + private (int[] BandExponentIndices, double MaxExponentValue, double PeakCoefficientMagnitude) ChooseExponents(double[] coefficients) + { + var bandCount = _exponentBands.Length; + var bandExponentIndices = new int[bandCount]; + var maxExponentValue = 0.0; + var peakCoefficientMagnitude = 0.0; + var position = 0; + var lastExponentIndex = InitialExponent; + + for (var band = 0; band < bandCount; band++) + { + var bandLength = _exponentBands[band]; + var bandEnd = Math.Min(position + bandLength, _coefsEnd); + + var bandMax = 0.0; + for (var i = position; i < bandEnd; i++) + { + bandMax = Math.Max(bandMax, Math.Abs(coefficients[i])); + } + + peakCoefficientMagnitude = Math.Max(peakCoefficientMagnitude, bandMax); + + var desiredExponentIndex = MagnitudeToExponentIndex(bandMax); + var clampedDelta = Math.Clamp(desiredExponentIndex - lastExponentIndex, -MaxExponentDeltaPerBand, MaxExponentDeltaPerBand); + lastExponentIndex += clampedDelta; + bandExponentIndices[band] = lastExponentIndex; + + var exponentValue = WmaTables.PowTable[Math.Clamp(lastExponentIndex + 60, 0, WmaTables.PowTable.Length - 1)]; + maxExponentValue = Math.Max(maxExponentValue, exponentValue); + + position += bandLength; + } + + if (maxExponentValue <= 0) + { + maxExponentValue = WmaTables.PowTable[60]; + } + + if (peakCoefficientMagnitude <= 0) + { + peakCoefficientMagnitude = maxExponentValue; + } + + return (bandExponentIndices, maxExponentValue, peakCoefficientMagnitude); + } + + // Solves for the totalGain that makes this channel's true peak coefficient quantize to + // TargetQuantizedPeak -- mirrors WmaDecoder's mult = 10^(totalGain*0.05) / maxExponentValue + // / (FrameLength/2). For the peak band, exponentValue == maxExponentValue, so it cancels + // out of quantized = coefficient / (exponentValue * mult) algebraically -- meaning totalGain + // must be solved from the peak band's true (unclamped) coefficient magnitude, not from + // maxExponentValue itself (which is clamped to PowTable's representable range and can be + // far smaller than the true peak for full-scale PCM input). + private int SolveTotalGain(double peakCoefficientMagnitude) + { + var totalGain = (int)Math.Round(20 * Math.Log10(peakCoefficientMagnitude * (FrameLength / 2.0) / TargetQuantizedPeak)) + 1; + + return Math.Clamp(totalGain, 1, 4000); + } + + private int[] QuantizeChannel(double[] coefficients, int[] bandExponentIndices, double maxExponentValue, int totalGain, int coefficientBitWidth) + { + var exponentValuePerPosition = new double[FrameLength]; + var position = 0; + for (var band = 0; band < _exponentBands.Length; band++) + { + var bandLength = _exponentBands[band]; + var exponentValue = WmaTables.PowTable[Math.Clamp(bandExponentIndices[band] + 60, 0, WmaTables.PowTable.Length - 1)]; + + for (var i = position; i < position + bandLength && i < FrameLength; i++) + { + exponentValuePerPosition[i] = exponentValue; + } + + position += bandLength; + } + + var mult = Math.Pow(10, totalGain * 0.05) / maxExponentValue / (FrameLength / 2.0); + var maxLevel = (1 << (coefficientBitWidth - 1)) - 1; + + var quantized = new int[FrameLength]; + for (var i = 0; i < _coefsEnd; i++) + { + if (exponentValuePerPosition[i] <= 0) + { + continue; + } + + var value = (int)Math.Round(coefficients[i] / (exponentValuePerPosition[i] * mult)); + quantized[i] = Math.Clamp(value, -maxLevel, maxLevel); + } + + return quantized; + } + + private static int MagnitudeToExponentIndex(double magnitude) + { + if (magnitude <= 0) + { + return -60; + } + + var index = (int)Math.Round(16 * Math.Log10(magnitude)); + + return Math.Clamp(index, -60, 95); + } + + private static void WriteGain(BitWriter writer, int totalGain) + { + var remaining = totalGain - 1; + while (remaining >= 127) + { + writer.WriteBits(127, 7); + remaining -= 127; + } + + writer.WriteBits((uint)remaining, 7); + } + + private static void WriteExponents(BitWriter writer, int[] bandExponentIndices) + { + var lastExponent = InitialExponent; + + foreach (var exponentIndex in bandExponentIndices) + { + var code = exponentIndex - lastExponent + Aac.AacTables.ScaleDiffZero; + lastExponent = exponentIndex; + + var (huffmanCode, length) = Aac.AacTables.ScalefactorHuffman.GetCode(code); + writer.WriteBits(huffmanCode, length); + } + } + + // Decode's coefficient loop is `for (offset=0; offset= _coefsEnd) + { + var (stopCode, stopLength) = WmaTables.Coef4Huffman.GetCode(1); + writer.WriteBits(stopCode, stopLength); + return; + } + + var run = position - runStart; + var level = Math.Abs(quantized[position]); + var isPositive = quantized[position] > 0; + var index = WmaTables.FindCoefficientIndex(run, level); + + if (index >= 0) + { + var (huffmanCode, length) = WmaTables.Coef4Huffman.GetCode(index); + writer.WriteBits(huffmanCode, length); + writer.WriteBits(isPositive ? 1u : 0u, 1); + } + else + { + var (escapeCode, escapeLength) = WmaTables.Coef4Huffman.GetCode(0); + writer.WriteBits(escapeCode, escapeLength); + writer.WriteBits((uint)Math.Min(level, (1 << coefficientBitWidth) - 1), coefficientBitWidth); + writer.WriteBits((uint)run, _frameLengthBits); + writer.WriteBits(isPositive ? 1u : 0u, 1); + } + + position++; + } + + // position landed exactly on _coefsEnd via a real (run, level)/escape symbol -- decode's + // loop condition will fail on its own before reading another symbol, so no trailing stop + // code belongs here. + } + + private static int TotalGainToBits(int totalGain) + { + if (totalGain < 15) + { + return 13; + } + + if (totalGain < 32) + { + return 12; + } + + if (totalGain < 40) + { + return 11; + } + + if (totalGain < 45) + { + return 10; + } + + return 9; + } + + private static int GetFrameLengthBits(int sampleRate) + { + if (sampleRate <= 16000) + { + return 9; + } + + if (sampleRate <= 22050) + { + return 10; + } + + return 11; + } + + private static ushort[] BuildExponentBands(int sampleRate, int blockLength) + { + var bands = new List(); + var lastPosition = 0; + + foreach (var criticalFrequency in WmaTables.CriticalFrequencies) + { + var position = ((blockLength * 2 * criticalFrequency) + (sampleRate << 1)) / (4 * sampleRate); + position <<= 2; + if (position > blockLength) + { + position = blockLength; + } + + if (position > lastPosition) + { + bands.Add((ushort)(position - lastPosition)); + } + + if (position >= blockLength) + { + break; + } + + lastPosition = position; + } + + return [.. bands]; + } + + private static double[] BuildSineWindow(int blockLength) + { + var window = new double[blockLength]; + for (var n = 0; n < blockLength; n++) + { + window[n] = Math.Sin((Math.PI / (2 * blockLength)) * (n + 0.5)); + } + + return window; + } + } +} diff --git a/src/EggEncoder/Codecs/Wma/WmaTables.cs b/src/EggEncoder/Codecs/Wma/WmaTables.cs index 4576c76..bc08968 100644 --- a/src/EggEncoder/Codecs/Wma/WmaTables.cs +++ b/src/EggEncoder/Codecs/Wma/WmaTables.cs @@ -237,5 +237,105 @@ internal static class WmaTables ]; public static readonly HuffmanTable Coef4Huffman = new HuffmanTable(Coef4Codes, Coef4Bits); + + // Coef4Levels is a run-length histogram: for level L (1-based), there are Coef4Levels[L-1] + // consecutive Huffman symbol indices (starting at 2, since 0 is the escape code and 1 is the + // end-of-coefficients marker) assigned to run = 0, 1, 2, ... up to Coef4Levels[L-1]-1. + public static (int[] RunTable, int[] LevelTable) BuildCoefficientRunLevelTables() + { + var runTable = new int[Coef4Bits.Length]; + var levelTable = new int[Coef4Bits.Length]; + + var index = 2; + var level = 1; + foreach (var runLength in Coef4Levels) + { + for (var j = 0; j < runLength; j++) + { + runTable[index] = j; + levelTable[index] = level; + index++; + } + + level++; + } + + return (runTable, levelTable); + } + + // Inverse of BuildCoefficientRunLevelTables: given a (run, level) pair, returns the + // Coef4Huffman symbol index that encodes it directly, or -1 if this combination isn't + // representable in the table -- the caller must fall back to the escape code (symbol 0). + public static int FindCoefficientIndex(int run, int level) + { + if (level < 1 || level > Coef4Levels.Length || run < 0 || run >= Coef4Levels[level - 1]) + { + return -1; + } + + var index = 2; + for (var precedingLevel = 1; precedingLevel < level; precedingLevel++) + { + index += Coef4Levels[precedingLevel - 1]; + } + + return index + run; + } + + public static int GetFrameLengthBits(int sampleRate) + { + if (sampleRate <= 16000) + { + return 9; + } + + if (sampleRate <= 22050) + { + return 10; + } + + return 11; + } + + public static ushort[] BuildExponentBands(int sampleRate, int blockLength) + { + var bands = new List(); + var lastPosition = 0; + + foreach (var criticalFrequency in CriticalFrequencies) + { + var position = ((blockLength * 2 * criticalFrequency) + (sampleRate << 1)) / (4 * sampleRate); + position <<= 2; + if (position > blockLength) + { + position = blockLength; + } + + if (position > lastPosition) + { + bands.Add((ushort)(position - lastPosition)); + } + + if (position >= blockLength) + { + break; + } + + lastPosition = position; + } + + return [.. bands]; + } + + public static double[] BuildSineWindow(int blockLength) + { + var window = new double[blockLength]; + for (var n = 0; n < blockLength; n++) + { + window[n] = Math.Sin((Math.PI / (2 * blockLength)) * (n + 0.5)); + } + + return window; + } } } From e8a92436149652554e89a450d08e7b5ab3110b69 Mon Sep 17 00:00:00 2001 From: Hai Vo Date: Thu, 6 Aug 2026 13:46:29 +0700 Subject: [PATCH 2/2] refactor: dedupe WMA frame-sizing/window/bit-width helpers into WmaTables WmaFrameEncoder had its own private copies of GetFrameLengthBits, BuildExponentBands, BuildSineWindow, and TotalGainToBits instead of the WmaTables versions WmaDecoder already uses. They were byte-for-byte identical so nothing was broken, but these four functions define the exact encoder/decoder bitstream contract -- an independent edit to one copy without the other would silently desync encode/decode with no compiler error, the same class of bug as the coefficient stop-code fix earlier in this branch. TotalGainToBits was also duplicated as a WmaDecoder-local function pre-dating this branch; moved it into WmaTables too so both sides share a single implementation. Co-Authored-By: Claude Sonnet 5 --- src/EggEncoder/Codecs/Wma/WmaDecoder.cs | 27 +----- src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs | 88 +------------------- src/EggEncoder/Codecs/Wma/WmaTables.cs | 29 +++++++ 3 files changed, 34 insertions(+), 110 deletions(-) diff --git a/src/EggEncoder/Codecs/Wma/WmaDecoder.cs b/src/EggEncoder/Codecs/Wma/WmaDecoder.cs index a3d0268..527be07 100644 --- a/src/EggEncoder/Codecs/Wma/WmaDecoder.cs +++ b/src/EggEncoder/Codecs/Wma/WmaDecoder.cs @@ -123,7 +123,7 @@ int[] levelTable totalGain += gainIncrement; } while (gainIncrement == 127); - var coefficientBitWidth = TotalGainToBits(totalGain); + var coefficientBitWidth = WmaTables.TotalGainToBits(totalGain); if (channels == 2 && reader.ReadBits(1) != 0) { @@ -183,31 +183,6 @@ int[] levelTable return channelCoefficients; - static int TotalGainToBits(int totalGain) - { - if (totalGain < 15) - { - return 13; - } - - if (totalGain < 32) - { - return 12; - } - - if (totalGain < 40) - { - return 11; - } - - if (totalGain < 45) - { - return 10; - } - - return 9; - } - static double DecodeExponents(BitReader reader, ushort[] exponentBands, double[] exponents) { var lastExponent = 36; diff --git a/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs b/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs index b09057e..a4796a9 100644 --- a/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs +++ b/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs @@ -27,11 +27,11 @@ internal sealed class WmaFrameEncoder public WmaFrameEncoder(int channels, int sampleRate) { _channels = channels; - _frameLengthBits = GetFrameLengthBits(sampleRate); + _frameLengthBits = WmaTables.GetFrameLengthBits(sampleRate); FrameLength = 1 << _frameLengthBits; _coefsEnd = FrameLength - (FrameLength * 9 / 100); - _exponentBands = BuildExponentBands(sampleRate, FrameLength); - _window = BuildSineWindow(FrameLength); + _exponentBands = WmaTables.BuildExponentBands(sampleRate, FrameLength); + _window = WmaTables.BuildSineWindow(FrameLength); _previousBlock = new double[channels][]; for (var channel = 0; channel < channels; channel++) @@ -67,7 +67,7 @@ public byte[] EncodeFrame(double[][] currentBlock) desiredTotalGain = Math.Max(desiredTotalGain, SolveTotalGain(peakCoefficientMagnitude)); } - var coefficientBitWidth = TotalGainToBits(desiredTotalGain); + var coefficientBitWidth = WmaTables.TotalGainToBits(desiredTotalGain); WriteGain(writer, desiredTotalGain); @@ -317,85 +317,5 @@ private void WriteCoefficients(BitWriter writer, int[] quantized, int coefficien // code belongs here. } - private static int TotalGainToBits(int totalGain) - { - if (totalGain < 15) - { - return 13; - } - - if (totalGain < 32) - { - return 12; - } - - if (totalGain < 40) - { - return 11; - } - - if (totalGain < 45) - { - return 10; - } - - return 9; - } - - private static int GetFrameLengthBits(int sampleRate) - { - if (sampleRate <= 16000) - { - return 9; - } - - if (sampleRate <= 22050) - { - return 10; - } - - return 11; - } - - private static ushort[] BuildExponentBands(int sampleRate, int blockLength) - { - var bands = new List(); - var lastPosition = 0; - - foreach (var criticalFrequency in WmaTables.CriticalFrequencies) - { - var position = ((blockLength * 2 * criticalFrequency) + (sampleRate << 1)) / (4 * sampleRate); - position <<= 2; - if (position > blockLength) - { - position = blockLength; - } - - if (position > lastPosition) - { - bands.Add((ushort)(position - lastPosition)); - } - - if (position >= blockLength) - { - break; - } - - lastPosition = position; - } - - return [.. bands]; - } - - private static double[] BuildSineWindow(int blockLength) - { - var window = new double[blockLength]; - for (var n = 0; n < blockLength; n++) - { - window[n] = Math.Sin((Math.PI / (2 * blockLength)) * (n + 0.5)); - } - - return window; - } } } diff --git a/src/EggEncoder/Codecs/Wma/WmaTables.cs b/src/EggEncoder/Codecs/Wma/WmaTables.cs index bc08968..9cef88c 100644 --- a/src/EggEncoder/Codecs/Wma/WmaTables.cs +++ b/src/EggEncoder/Codecs/Wma/WmaTables.cs @@ -282,6 +282,35 @@ public static int FindCoefficientIndex(int run, int level) return index + run; } + // Number of bits used to store an escaped coefficient level (and, on decode, the width + // DecodeCoefficients reads escaped levels as) -- coarser for louder frames (higher + // totalGain already carries more of the dynamic range) so louder content costs fewer bits + // per escaped coefficient. + public static int TotalGainToBits(int totalGain) + { + if (totalGain < 15) + { + return 13; + } + + if (totalGain < 32) + { + return 12; + } + + if (totalGain < 40) + { + return 11; + } + + if (totalGain < 45) + { + return 10; + } + + return 9; + } + public static int GetFrameLengthBits(int sampleRate) { if (sampleRate <= 16000)