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); }