eddbb00cd9
Convert float to 24-bit PCM and repack padded containers on normalize; vault still stores standard PCM.
228 lines
9.2 KiB
C#
228 lines
9.2 KiB
C#
using System.Text;
|
|
using DeepDrftContent.Processors;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="AudioProcessor.ProcessWavFileAsync"/> WAV format handling — standard PCM,
|
|
/// EXTENSIBLE-PCM, EXTENSIBLE IEEE float, padded 24-in-32, and the default-fallback paths.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
// -- helpers --------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="sampleData"/> is
|
|
/// null a small block of silence sized to the block alignment is used.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Builds a 16-byte SubFormat GUID whose leading 2 bytes are the format tag.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Packs each 24-bit sample value into a 32-bit little-endian container.</summary>
|
|
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<string> 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);
|
|
}
|