using System.Text;
using DeepDrftContent.Processors;
namespace DeepDrftTests;
///
/// Tests for WAV format handling — standard PCM,
/// EXTENSIBLE-PCM, EXTENSIBLE IEEE float, padded 24-in-32, and the default-fallback paths.
///
[TestFixture]
public class AudioProcessorTests
{
private const ushort WaveFormatPcm = 0x0001;
private const ushort WaveFormatExtensible = 0xFFFE;
private string _testDir = string.Empty;
[SetUp]
public void SetUp()
{
_testDir = Path.Combine(Path.GetTempPath(), "AudioProcessorTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
[TearDown]
public void TearDown()
{
try { Directory.Delete(_testDir, recursive: true); }
catch { /* Best-effort cleanup — ignore failures */ }
}
[Test]
public async Task StandardPcm_RoundTripsUnchanged()
{
var path = await WriteWavAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm));
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
Assert.That(audio.Bitrate, Is.GreaterThan(0));
}
[Test]
public async Task ExtensiblePcm_NormalizesToStandardHeader()
{
var subFormat = SubFormatGuid(WaveFormatPcm);
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
subFormatGuid: subFormat, validBitsPerSample: 16);
var path = await WriteWavAsync(wav);
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.GreaterThan(0.0));
Assert.That(audio.Bitrate, Is.GreaterThan(0));
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm), "Stored buffer must be standard PCM");
}
[Test]
public async Task ExtensibleIeeeFloat_AcceptedAndConverted()
{
// Two stereo frames of 32-bit float samples (range [-1.0, 1.0]).
var samples = FloatBytes(0.5f, -0.5f, 1.0f, -1.0f);
var subFormat = SubFormatGuid(0x0003); // WAVE_FORMAT_IEEE_FLOAT
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 32);
var path = await WriteWavAsync(wav);
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(16 + 8), "Float must convert to 24-bit PCM");
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
// 4 float samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes after the 44-byte header.
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
// Verify the converted sample values: (int)(sample * 8388607.0), clamped, little-endian 3 bytes.
// 0.5f → 4194303 = 0x3FFFFF → FF FF 3F
// -0.5f → -4194303 = 0xFFC00001 → 24-bit LE: 01 00 C0
// 1.0f → 8388607 = 0x7FFFFF → FF FF 7F
// -1.0f → -8388607 = 0xFF800001 → 24-bit LE: 01 00 80
var expectedData = new byte[] { 0xFF, 0xFF, 0x3F, 0x01, 0x00, 0xC0, 0xFF, 0xFF, 0x7F, 0x01, 0x00, 0x80 };
var actualData = audio.Buffer[44..];
Assert.That(actualData, Is.EqualTo(expectedData), "Float samples must be converted to 24-bit PCM correctly");
}
[Test]
public async Task ExtensiblePadded24in32_AcceptedAndRepacked()
{
// Two stereo frames; each sample is a 24-bit value stored in a 32-bit little-endian container.
var samples = Padded24In32Bytes(0x123456, unchecked((int)0xFFEDCBA9), 0x000001, unchecked((int)0xFF800000));
var subFormat = SubFormatGuid(WaveFormatPcm);
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
sampleData: samples, subFormatGuid: subFormat, validBitsPerSample: 24);
var path = await WriteWavAsync(wav);
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(ReadFmtBitsPerSample(audio!.Buffer), Is.EqualTo(24), "Padded container must repack to 24-bit");
Assert.That(ReadFmtAudioFormat(audio.Buffer), Is.EqualTo(WaveFormatPcm));
// 4 container samples (4 bytes each) → 4 PCM samples (3 bytes each) = 12 data bytes.
Assert.That(audio.Buffer.Length, Is.EqualTo(44 + 12));
// Verify the repacked sample values: lowest 3 bytes of each 4-byte little-endian container.
// 0x123456 → LE 4 bytes: 56 34 12 00 → keep 3: 56 34 12
// 0xFFEDCBA9 → LE 4 bytes: A9 CB ED FF → keep 3: A9 CB ED
// 0x000001 → LE 4 bytes: 01 00 00 00 → keep 3: 01 00 00
// 0xFF800000 → LE 4 bytes: 00 00 80 FF → keep 3: 00 00 80
var expectedData = new byte[] { 0x56, 0x34, 0x12, 0xA9, 0xCB, 0xED, 0x01, 0x00, 0x00, 0x00, 0x00, 0x80 };
var actualData = audio.Buffer[44..];
Assert.That(actualData, Is.EqualTo(expectedData), "Padded 24-in-32 samples must strip the padding byte correctly");
}
[Test]
public async Task ExtensibleUnsupportedSubFormat_FallsBackToDefaults()
{
var subFormat = SubFormatGuid(0x0005); // WAVE_FORMAT_DOLBY_AC3 — neither PCM nor float
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
subFormatGuid: subFormat, validBitsPerSample: 16);
var path = await WriteWavAsync(wav);
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unsupported SubFormat must fall back to default metadata");
}
[Test]
public async Task ExtensibleFmtTooSmall_FallsBackToDefaults()
{
// audioFormat=EXTENSIBLE but fmt chunk declares 16 bytes — too small for the extension.
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatExtensible,
forceFmtChunkSize: 16);
var path = await WriteWavAsync(wav);
var audio = await new AudioProcessor().ProcessWavFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Undersized EXTENSIBLE fmt chunk must fall back to default metadata");
}
[Test]
public void TryExtractPcm_FloatWav_ReturnsNull()
{
var subFormat = SubFormatGuid(0x0003);
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
subFormatGuid: subFormat, validBitsPerSample: 32);
var result = new AudioProcessor().TryExtractPcm(wav);
Assert.That(result, Is.Null);
}
[Test]
public void TryExtractPcm_Padded24in32_ReturnsNull()
{
var subFormat = SubFormatGuid(WaveFormatPcm);
var wav = BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 32, audioFormat: WaveFormatExtensible,
subFormatGuid: subFormat, validBitsPerSample: 24);
var result = new AudioProcessor().TryExtractPcm(wav);
Assert.That(result, Is.Null);
}
// -- MP3 ------------------------------------------------------------------------------------
[Test]
public async Task Mp3_CbrMetadata_ParsedCorrectly()
{
// BuildMinimalMp3: bitrateKbps=128, sampleRate=44100, stereo=true, no Xing tag, no ID3 tag.
// frameSize = floor(144 * 128000 / 44100) = 417 bytes; bufferSize = max(417, 48) = 417.
// CBR duration = (bufferLength - frameStart) / (bitrateKbps * 125) = 417 / 16000 ≈ 0.0261 s.
const int bitrateKbps = 128;
const int sampleRate = 44100;
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate); // 417
var bufferSize = Math.Max(frameSize, 4 + 32 + 12); // max(417, 48) = 417
var expectedDuration = (double)bufferSize / (bitrateKbps * 125); // frameStart = 0 (no ID3)
var path = await WriteAudioAsync(BuildMinimalMp3(bitrateKbps: bitrateKbps, sampleRate: sampleRate, stereo: true), ".mp3");
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Extension, Is.EqualTo(".mp3"));
Assert.That(audio.Bitrate, Is.EqualTo(bitrateKbps));
Assert.That(audio.Duration, Is.EqualTo(expectedDuration).Within(0.01));
}
[Test]
public async Task Mp3_VbrWithXingHeader_DurationFromXing()
{
const int frameCount = 1000;
const int sampleRate = 44100;
var path = await WriteAudioAsync(
BuildMinimalMp3(bitrateKbps: 128, sampleRate: sampleRate, stereo: true, addXingHeader: true, xingFrameCount: frameCount),
".mp3");
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
Assert.That(audio, Is.Not.Null);
var expected = (double)frameCount * 1152 / sampleRate;
Assert.That(audio!.Duration, Is.EqualTo(expected).Within(0.001), "VBR duration must come from the Xing frame count");
}
[Test]
public async Task Mp3_InvalidFile_FallsBackToDefaults()
{
// A standard PCM WAV has no MPEG frame sync — the parser must fall back to defaults.
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".mp3");
var audio = await new Mp3AudioProcessor().ProcessMp3FileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable MP3 must fall back to default duration");
}
// -- FLAC -----------------------------------------------------------------------------------
[Test]
public async Task Flac_StreaminfoMetadata_ParsedCorrectly()
{
var path = await WriteAudioAsync(
BuildMinimalFlac(sampleRate: 44100, channels: 2, bitsPerSample: 24, totalSamples: 441000),
".flac");
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Extension, Is.EqualTo(".flac"));
Assert.That(audio.Duration, Is.EqualTo(10.0).Within(0.01), "441000 samples / 44100 Hz = 10 s");
Assert.That(audio.Bitrate, Is.GreaterThan(0));
}
[Test]
public async Task Flac_InvalidFile_FallsBackToDefaults()
{
// A standard PCM WAV lacks the fLaC magic — the parser must fall back to defaults.
var path = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".flac");
var audio = await new FlacAudioProcessor().ProcessFlacFileAsync(path);
Assert.That(audio, Is.Not.Null);
Assert.That(audio!.Duration, Is.EqualTo(180.0), "Unparseable FLAC must fall back to default duration");
}
// -- Router ---------------------------------------------------------------------------------
[Test]
public async Task Router_DispatchesByExtension()
{
var router = new AudioProcessorRouter(new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor());
var wavPath = await WriteAudioAsync(BuildMinimalWav(channels: 2, sampleRate: 44100, bitsPerSample: 16, audioFormat: WaveFormatPcm), ".wav");
var mp3Path = await WriteAudioAsync(BuildMinimalMp3(), ".mp3");
var flacPath = await WriteAudioAsync(BuildMinimalFlac(), ".flac");
var wav = await router.ProcessAudioFileAsync(wavPath);
var mp3 = await router.ProcessAudioFileAsync(mp3Path);
var flac = await router.ProcessAudioFileAsync(flacPath);
Assert.That(wav, Is.Not.Null);
Assert.That(wav!.Extension, Is.EqualTo(".wav"));
Assert.That(mp3, Is.Not.Null);
Assert.That(mp3!.Extension, Is.EqualTo(".mp3"));
Assert.That(flac, Is.Not.Null);
Assert.That(flac!.Extension, Is.EqualTo(".flac"));
}
// -- helpers --------------------------------------------------------------------------------
///
/// Synthesises a minimal valid MPEG1 Layer III CBR MP3 buffer: one frame header plus enough body
/// bytes for the frame, with an optional Xing VBR header in the side-information region. The body
/// is zero-filled (silence) — only the header and any Xing tag are meaningful to the parser.
///
private static byte[] BuildMinimalMp3(
int bitrateKbps = 128,
int sampleRate = 44100,
bool stereo = true,
bool addXingHeader = false,
int xingFrameCount = 0)
{
// MPEG1 Layer III bitrate index for the kbps table [0,32,40,48,56,64,80,96,112,128,160,192,224,256,320].
var bitrateIndex = Array.IndexOf(
new[] { 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 }, bitrateKbps);
var sampleRateIndex = Array.IndexOf(new[] { 44100, 48000, 32000 }, sampleRate);
// Byte 1: 1111 1011 = sync(111) version(11=MPEG1) layer(01=Layer III) protection(1=no CRC).
const byte b1 = 0xFB;
// Byte 2: bitrate index (4) | sample-rate index (2) | padding (1=0) | private (1=0).
var b2 = (byte)((bitrateIndex << 4) | (sampleRateIndex << 2));
// Byte 3: channel mode (2) | mode ext (2) | copyright (1) | original (1) | emphasis (2).
// 00 = stereo, 11 = mono.
var b3 = (byte)(stereo ? 0x00 : 0xC0);
var frameSize = (int)Math.Floor(144.0 * (bitrateKbps * 1000) / sampleRate);
// Side-info size for MPEG1: 32 bytes stereo, 17 bytes mono. Xing tag sits just past it.
var sideInfoSize = stereo ? 32 : 17;
var bufferSize = Math.Max(frameSize, 4 + sideInfoSize + 12);
var buffer = new byte[bufferSize];
buffer[0] = 0xFF;
buffer[1] = b1;
buffer[2] = b2;
buffer[3] = b3;
if (addXingHeader)
{
var tagPos = 4 + sideInfoSize;
buffer[tagPos] = (byte)'X';
buffer[tagPos + 1] = (byte)'i';
buffer[tagPos + 2] = (byte)'n';
buffer[tagPos + 3] = (byte)'g';
// Flags: bit 0 set = frame count present.
buffer[tagPos + 7] = 0x01;
// Frame count: big-endian uint32 at tag offset 8.
buffer[tagPos + 8] = (byte)((xingFrameCount >> 24) & 0xFF);
buffer[tagPos + 9] = (byte)((xingFrameCount >> 16) & 0xFF);
buffer[tagPos + 10] = (byte)((xingFrameCount >> 8) & 0xFF);
buffer[tagPos + 11] = (byte)(xingFrameCount & 0xFF);
}
return buffer;
}
///
/// Synthesises a minimal FLAC buffer: fLaC magic, a STREAMINFO metadata block header, a
/// 34-byte STREAMINFO body carrying the sample rate, channel count, bit depth, and total sample
/// count, plus of trailing zero bytes standing in for the encoded
/// audio frames. The trailing bytes only affect the average-bitrate computation (file size); the
/// parser ignores their content. Other STREAMINFO fields are left zero — the parser ignores them.
///
private static byte[] BuildMinimalFlac(
int sampleRate = 44100,
int channels = 2,
int bitsPerSample = 24,
long totalSamples = 441000,
int audioDataBytes = 256_000)
{
var buffer = new byte[4 + 4 + 34 + audioDataBytes];
// Magic.
buffer[0] = (byte)'f';
buffer[1] = (byte)'L';
buffer[2] = (byte)'a';
buffer[3] = (byte)'C';
// Metadata block header: byte 0 = is_last(1) | block type(7=0 STREAMINFO); bytes 1-3 = length 34.
buffer[4] = 0x80; // last block, type 0
buffer[5] = 0x00;
buffer[6] = 0x00;
buffer[7] = 34;
// STREAMINFO body begins at offset 8. We only set the bit-packed fields the parser reads:
// bytes 10-12 + top nibble of 13: sample rate (20 bits)
// bits 3-1 of byte 12: channels - 1
// bit 0 of byte 12 + top nibble of 13: bits per sample - 1
// low nibble of byte 13 + bytes 14-17: total samples (36 bits)
var d = 8;
// 20-bit sample rate split across bytes 10, 11, and the top nibble of 12.
buffer[d + 10] = (byte)((sampleRate >> 12) & 0xFF);
buffer[d + 11] = (byte)((sampleRate >> 4) & 0xFF);
var bps = bitsPerSample - 1; // 5 bits: bit 0 of byte 12 + top 4 bits of byte 13
var ch = channels - 1; // 3 bits: bits 3-1 of byte 12
// Byte 12: [sampleRate low nibble (4)] [channels-1 (3)] [bps-1 high bit (1)].
buffer[d + 12] = (byte)(((sampleRate & 0x0F) << 4) | ((ch & 0x07) << 1) | ((bps >> 4) & 0x01));
// Byte 13: [bps-1 low 4 bits (4)] [total samples bits 35-32 (4)].
buffer[d + 13] = (byte)(((bps & 0x0F) << 4) | (int)((totalSamples >> 32) & 0x0F));
// Bytes 14-17: total samples low 32 bits, big-endian.
buffer[d + 14] = (byte)((totalSamples >> 24) & 0xFF);
buffer[d + 15] = (byte)((totalSamples >> 16) & 0xFF);
buffer[d + 16] = (byte)((totalSamples >> 8) & 0xFF);
buffer[d + 17] = (byte)(totalSamples & 0xFF);
return buffer;
}
private async Task WriteAudioAsync(byte[] bytes, string extension)
{
var path = Path.Combine(_testDir, Guid.NewGuid() + extension);
await File.WriteAllBytesAsync(path, bytes);
return path;
}
///
/// Synthesises a minimal valid WAV buffer. For EXTENSIBLE (audioFormat=0xFFFE) the fmt chunk is
/// 40 bytes and includes cbSize, wValidBitsPerSample, channel mask, and the SubFormat GUID. For
/// standard PCM (audioFormat=1) the fmt chunk is 16 bytes. When is
/// null a small block of silence sized to the block alignment is used.
///
private static byte[] BuildMinimalWav(
int channels,
int sampleRate,
int bitsPerSample,
ushort audioFormat,
byte[]? sampleData = null,
byte[]? subFormatGuid = null,
ushort validBitsPerSample = 0,
uint? forceFmtChunkSize = null)
{
var isExtensible = audioFormat == WaveFormatExtensible;
var fmtChunkSize = forceFmtChunkSize ?? (isExtensible ? 40u : 16u);
var blockAlign = (ushort)(channels * (bitsPerSample / 8));
var byteRate = (uint)(sampleRate * blockAlign);
var data = sampleData ?? new byte[blockAlign * 2];
using var ms = new MemoryStream();
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
w.Write(Encoding.ASCII.GetBytes("RIFF"));
w.Write((uint)(36 + fmtChunkSize - 16 + data.Length)); // riff size adjusted for fmt extension
w.Write(Encoding.ASCII.GetBytes("WAVE"));
w.Write(Encoding.ASCII.GetBytes("fmt "));
w.Write(fmtChunkSize);
w.Write(audioFormat);
w.Write((ushort)channels);
w.Write((uint)sampleRate);
w.Write(byteRate);
w.Write(blockAlign);
w.Write((ushort)bitsPerSample);
// Only emit the EXTENSIBLE extension when the declared fmt size actually allows for it. A
// forced-small size (fmt=16) leaves audioFormat=EXTENSIBLE but no extension, exercising the
// "fmt too small" fallback.
if (fmtChunkSize >= 40)
{
w.Write((ushort)22); // cbSize
w.Write(validBitsPerSample);
w.Write((uint)0); // channel mask
w.Write(subFormatGuid ?? SubFormatGuid(WaveFormatPcm));
}
w.Write(Encoding.ASCII.GetBytes("data"));
w.Write((uint)data.Length);
w.Write(data);
w.Flush();
return ms.ToArray();
}
/// Builds a 16-byte SubFormat GUID whose leading 2 bytes are the format tag.
private static byte[] SubFormatGuid(ushort formatTag)
{
var guid = new byte[16];
guid[0] = (byte)(formatTag & 0xFF);
guid[1] = (byte)((formatTag >> 8) & 0xFF);
// Remaining 14 bytes are the fixed KSDATAFORMAT suffix; their value is irrelevant to parsing.
return guid;
}
private static byte[] FloatBytes(params float[] samples)
{
var bytes = new byte[samples.Length * 4];
for (int i = 0; i < samples.Length; i++)
{
BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
}
return bytes;
}
/// Packs each 24-bit sample value into a 32-bit little-endian container.
private static byte[] Padded24In32Bytes(params int[] samples)
{
var bytes = new byte[samples.Length * 4];
for (int i = 0; i < samples.Length; i++)
{
BitConverter.GetBytes(samples[i]).CopyTo(bytes, i * 4);
}
return bytes;
}
private async Task WriteWavAsync(byte[] wav)
{
var path = Path.Combine(_testDir, Guid.NewGuid() + ".wav");
await File.WriteAllBytesAsync(path, wav);
return path;
}
private static ushort ReadFmtAudioFormat(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 20);
private static ushort ReadFmtBitsPerSample(byte[] standardPcmWav) => BitConverter.ToUInt16(standardPcmWav, 34);
}