feat(audio): accept EXTENSIBLE IEEE-float and padded 24-in-32 WAV

Convert float to 24-bit PCM and repack padded containers on normalize; vault still stores standard PCM.
This commit is contained in:
daniel-c-harvey
2026-06-10 20:04:55 -04:00
parent b4cda76114
commit eddbb00cd9
2 changed files with 384 additions and 22 deletions
+157 -22
View File
@@ -69,11 +69,23 @@ public class AudioProcessor
return null;
}
// Float and padded-container EXTENSIBLE require a sample-level transform to become integer PCM.
// TryExtractPcm feeds loudness analysis, not storage, and must not hand back float bytes
// mislabeled as integer PCM — out of scope here, so treat them as "no profile computable".
if (validation.IsFloat)
{
return null;
}
WavMetadata metadata;
try
{
metadata = ParseWavMetadata(bytes, validation);
ValidateAudioParameters(metadata);
if (metadata.IsPaddedContainer)
{
return null;
}
}
catch
{
@@ -162,10 +174,12 @@ public class AudioProcessor
}
// Validate audio format. Standard PCM (1) is accepted directly. WAVE_FORMAT_EXTENSIBLE
// (0xFFFE) is accepted only when its SubFormat GUID indicates PCM — the raw sample data is
// then byte-identical to standard PCM and we normalize it downstream.
// (0xFFFE) is accepted when its SubFormat GUID indicates PCM (0x0001) or IEEE float
// (0x0003). PCM sample data is byte-identical to standard PCM; float data is converted to
// 24-bit PCM downstream. Either way the vault only ever holds standard PCM.
var audioFormat = BitConverter.ToUInt16(buffer, fmtChunkPos + 8);
var isExtensible = false;
var isFloat = false;
if (audioFormat == 0xFFFE)
{
// EXTENSIBLE requires the full extension: 16 base + 2 cbSize + 22 extension = 40 bytes.
@@ -180,14 +194,23 @@ public class AudioProcessor
}
// SubFormat GUID begins 24 bytes into the fmt chunk data (fmtChunkPos + 8 + 24). Its
// first two bytes are the little-endian format tag; 0x0001 == WAVE_FORMAT_PCM.
// first two bytes are the little-endian format tag: 0x0001 == WAVE_FORMAT_PCM,
// 0x0003 == WAVE_FORMAT_IEEE_FLOAT.
var subFormatPos = fmtChunkPos + 8 + 24;
if (buffer[subFormatPos] != 0x01 || buffer[subFormatPos + 1] != 0x00)
var subFormatTag = BitConverter.ToUInt16(buffer, subFormatPos);
if (subFormatTag == 0x0001)
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is not PCM" };
isExtensible = true;
}
else if (subFormatTag == 0x0003)
{
isExtensible = true;
isFloat = true;
}
else
{
return new WavValidationResult { IsValid = false, ErrorMessage = "Invalid data: EXTENSIBLE SubFormat is neither PCM nor IEEE float" };
}
isExtensible = true;
}
else if (audioFormat != 1)
{
@@ -206,7 +229,8 @@ public class AudioProcessor
IsValid = true,
FmtChunkPos = fmtChunkPos,
DataChunkPos = dataChunkPos,
IsExtensible = isExtensible
IsExtensible = isExtensible,
IsFloat = isFloat
};
}
@@ -224,13 +248,19 @@ public class AudioProcessor
// For EXTENSIBLE the offset-22 field is the container width; the true sample depth lives in
// wValidBitsPerSample (fmtChunkPos + 8 + 18). They usually match (Bandcamp 24-bit = 24/24)
// but the valid bits are authoritative for the normalized header and metadata.
// Note: padded-container EXTENSIBLE (e.g. 24-bit valid in a 32-bit container) is not yet
// supported — the mismatched BlockAlign will cause ValidateAudioParameters to throw and fall
// back to defaults. This is an accepted gap as of this fix.
// but the valid bits are authoritative for the normalized header and metadata. When they
// differ (e.g. 24-bit valid in a 32-bit container) we keep the container width separately so
// ValidateAudioParameters can reconcile against the header BlockAlign and NormalizeToStandardPcm
// can re-pack the padded frames.
var containerBitsPerSample = 0;
if (validation.IsExtensible)
{
bitsPerSample = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
var validBits = BitConverter.ToUInt16(buffer, validation.FmtChunkPos + 8 + 18);
if (validBits != bitsPerSample)
{
containerBitsPerSample = bitsPerSample;
}
bitsPerSample = validBits;
}
var duration = byteRate > 0 ? (double)dataSize / byteRate : 0.0;
@@ -243,10 +273,12 @@ public class AudioProcessor
SampleRate = (int)sampleRate,
Channels = channels,
BitsPerSample = bitsPerSample,
ContainerBitsPerSample = containerBitsPerSample,
BlockAlign = blockAlign,
DataSize = (int)dataSize,
DataChunkPos = validation.DataChunkPos,
IsExtensible = validation.IsExtensible
IsExtensible = validation.IsExtensible,
IsFloat = validation.IsFloat
};
}
@@ -273,7 +305,11 @@ public class AudioProcessor
throw new InvalidDataException($"Unsupported bit depth: {metadata.BitsPerSample}");
}
var expectedBlockAlign = metadata.Channels * (metadata.BitsPerSample / 8);
// The header BlockAlign reflects the container width, not the valid bit depth. For a padded
// EXTENSIBLE container (e.g. 24-in-32) the container width is authoritative for this check;
// NormalizeToStandardPcm re-packs the frames down to the valid depth afterwards.
var blockAlignBits = metadata.IsPaddedContainer ? metadata.ContainerBitsPerSample : metadata.BitsPerSample;
var expectedBlockAlign = metadata.Channels * (blockAlignBits / 8);
if (metadata.BlockAlign != expectedBlockAlign)
{
throw new InvalidDataException($"Invalid block align: expected {expectedBlockAlign}, got {metadata.BlockAlign}");
@@ -281,21 +317,49 @@ public class AudioProcessor
}
/// <summary>
/// Rebuilds an EXTENSIBLE-PCM WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1).
/// The sample bytes are copied verbatim — EXTENSIBLE-PCM data is byte-identical to standard PCM —
/// only the header is replaced, so the vault stores a format the streaming pipeline already handles.
/// Rebuilds an EXTENSIBLE WAV as a canonical 44-byte-header standard PCM WAV (audioFormat = 1)
/// so the vault only ever holds a format the streaming pipeline already handles. Three source
/// shapes are normalized:
/// <list type="bullet">
/// <item>EXTENSIBLE-PCM (depth == container): sample bytes are byte-identical to standard PCM and
/// copied verbatim; only the header is replaced.</item>
/// <item>IEEE float: 32-bit float samples are converted to 24-bit signed integer PCM.</item>
/// <item>Padded container (e.g. 24-in-32): the padding/sign-extension bytes are stripped, keeping
/// the lowest valid bytes per sample.</item>
/// </list>
/// The output header always reports the valid bit depth (<see cref="WavMetadata.BitsPerSample"/>).
/// </summary>
private byte[] NormalizeToStandardPcm(byte[] buffer, WavMetadata metadata)
{
// Clamp the declared data size to what is actually present; some encoders overshoot.
var dataStart = metadata.DataChunkPos + 8;
var available = buffer.Length - dataStart;
var dataSize = Math.Min(metadata.DataSize, available);
var srcDataSize = Math.Min(metadata.DataSize, available);
byte[] dataBytes;
int outBitsPerSample;
if (metadata.IsFloat)
{
dataBytes = ConvertFloatTo24BitPcm(buffer, dataStart, srcDataSize);
outBitsPerSample = 24;
}
else if (metadata.IsPaddedContainer)
{
dataBytes = RepackPaddedContainer(buffer, dataStart, srcDataSize, metadata.ContainerBitsPerSample, metadata.BitsPerSample);
outBitsPerSample = metadata.BitsPerSample;
}
else
{
dataBytes = new byte[srcDataSize];
Array.Copy(buffer, dataStart, dataBytes, 0, srcDataSize);
outBitsPerSample = metadata.BitsPerSample;
}
var dataSize = dataBytes.Length;
const int headerSize = 44;
var result = new byte[headerSize + dataSize];
var blockAlign = (ushort)(metadata.Channels * (metadata.BitsPerSample / 8));
var blockAlign = (ushort)(metadata.Channels * (outBitsPerSample / 8));
var byteRate = (uint)(metadata.SampleRate * blockAlign);
// RIFF header
@@ -311,17 +375,70 @@ public class AudioProcessor
BitConverter.GetBytes((uint)metadata.SampleRate).CopyTo(result, 24);
BitConverter.GetBytes(byteRate).CopyTo(result, 28);
BitConverter.GetBytes(blockAlign).CopyTo(result, 32);
BitConverter.GetBytes((ushort)metadata.BitsPerSample).CopyTo(result, 34);
BitConverter.GetBytes((ushort)outBitsPerSample).CopyTo(result, 34);
// data chunk
System.Text.Encoding.ASCII.GetBytes("data").CopyTo(result, 36);
BitConverter.GetBytes((uint)dataSize).CopyTo(result, 40);
Array.Copy(buffer, dataStart, result, headerSize, dataSize);
Array.Copy(dataBytes, 0, result, headerSize, dataSize);
return result;
}
/// <summary>
/// Converts 32-bit little-endian IEEE float samples (range [-1.0, 1.0]) to 24-bit signed PCM.
/// Each 4-byte source sample becomes 3 little-endian output bytes; output size is 3/4 of input.
/// Trailing bytes that do not form a complete 4-byte sample are ignored.
/// </summary>
private static byte[] ConvertFloatTo24BitPcm(byte[] buffer, int dataStart, int dataSize)
{
var sampleCount = dataSize / 4;
var output = new byte[sampleCount * 3];
for (int i = 0; i < sampleCount; i++)
{
var sample = BitConverter.ToSingle(buffer, dataStart + i * 4);
var value = (int)(sample * 8388607.0);
value = Math.Clamp(value, -8388608, 8388607);
var o = i * 3;
output[o] = (byte)(value & 0xFF);
output[o + 1] = (byte)((value >> 8) & 0xFF);
output[o + 2] = (byte)((value >> 16) & 0xFF);
}
return output;
}
/// <summary>
/// Strips container padding from a padded-container EXTENSIBLE WAV (e.g. 24-bit valid samples
/// stored in 32-bit containers), keeping only the lowest <paramref name="validBits"/> bytes of
/// each little-endian sample. Output size is (validBits/containerBits) of input.
/// Trailing bytes that do not form a complete container sample are ignored.
/// </summary>
private static byte[] RepackPaddedContainer(byte[] buffer, int dataStart, int dataSize, int containerBits, int validBits)
{
var containerBytes = containerBits / 8;
var validBytes = validBits / 8;
var sampleCount = dataSize / containerBytes;
var output = new byte[sampleCount * validBytes];
for (int i = 0; i < sampleCount; i++)
{
var src = dataStart + i * containerBytes;
var dst = i * validBytes;
// Little-endian: the valid sample occupies the low bytes; the upper bytes are padding /
// sign extension and are discarded.
for (int b = 0; b < validBytes; b++)
{
output[dst + b] = buffer[src + b];
}
}
return output;
}
/// <summary>
/// Returns default WAV metadata for fallback scenarios
/// </summary>
@@ -389,11 +506,26 @@ public class AudioProcessor
public int Bitrate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
/// <summary>The valid sample depth — for EXTENSIBLE, wValidBitsPerSample.</summary>
public int BitsPerSample { get; set; }
/// <summary>
/// The container sample width for a padded EXTENSIBLE WAV whose valid depth is narrower
/// (e.g. 32 for a 24-in-32 file). Zero when the container matches the valid depth.
/// </summary>
public int ContainerBitsPerSample { get; set; }
public int BlockAlign { get; set; }
public int DataSize { get; set; }
public int DataChunkPos { get; set; }
public bool IsExtensible { get; set; }
/// <summary>True when the SubFormat is IEEE float (converted to 24-bit PCM on normalization).</summary>
public bool IsFloat { get; set; }
/// <summary>True when valid samples are stored in a wider container that must be re-packed.</summary>
public bool IsPaddedContainer => ContainerBitsPerSample != 0 && ContainerBitsPerSample != BitsPerSample;
}
/// <summary>
@@ -406,6 +538,9 @@ public class AudioProcessor
public int FmtChunkPos { get; set; }
public int DataChunkPos { get; set; }
public bool IsExtensible { get; set; }
/// <summary>True when the EXTENSIBLE SubFormat is IEEE float rather than PCM.</summary>
public bool IsFloat { get; set; }
}
}