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 @@
EggEncoder.Codecs.Wmapublic 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.
EggEncoder.Codecs.Movpublic 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..527be07 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,
@@ -201,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)
{
@@ -261,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/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..a4796a9
--- /dev/null
+++ b/src/EggEncoder/Codecs/Wma/WmaFrameEncoder.cs
@@ -0,0 +1,321 @@
+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 = WmaTables.GetFrameLengthBits(sampleRate);
+ FrameLength = 1 << _frameLengthBits;
+ _coefsEnd = FrameLength - (FrameLength * 9 / 100);
+ _exponentBands = WmaTables.BuildExponentBands(sampleRate, FrameLength);
+ _window = WmaTables.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 = WmaTables.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.
+ }
+
+ }
+}
diff --git a/src/EggEncoder/Codecs/Wma/WmaTables.cs b/src/EggEncoder/Codecs/Wma/WmaTables.cs
index 4576c76..9cef88c 100644
--- a/src/EggEncoder/Codecs/Wma/WmaTables.cs
+++ b/src/EggEncoder/Codecs/Wma/WmaTables.cs
@@ -237,5 +237,134 @@ 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;
+ }
+
+ // 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)
+ {
+ 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;
+ }
}
}