aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Audio/WavReader.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Audio/WavReader.cs')
-rw-r--r--src/Godosa.Core/Audio/WavReader.cs157
1 files changed, 157 insertions, 0 deletions
diff --git a/src/Godosa.Core/Audio/WavReader.cs b/src/Godosa.Core/Audio/WavReader.cs
new file mode 100644
index 0000000..f587109
--- /dev/null
+++ b/src/Godosa.Core/Audio/WavReader.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Buffers.Binary;
+
+namespace Godosa.Core.Audio;
+
+public readonly record struct PcmClip(short[] Samples, int SampleRate);
+
+/// <summary>WAV files for sound overrides (audio spec §3): 8/16/24-bit PCM, 32-bit float or
+/// Microsoft ADPCM (4-bit, format 2), plain or extensible header, any channel count, downmixed
+/// to mono (OpenAL only spatialises mono). Anything else → null.</summary>
+public static class WavReader
+{
+ private const int FormatPcm = 1, FormatMsAdpcm = 2, FormatFloat = 3, FormatExtensible = 0xFFFE;
+
+ public static PcmClip? Read(ReadOnlySpan<byte> file)
+ {
+ if (file.Length < 12 || !file[..4].SequenceEqual("RIFF"u8) || !file[8..12].SequenceEqual("WAVE"u8)) return null;
+ int pos = 12, format = 0, channels = 0, rate = 0, bits = 0, blockAlign = 0, factFrames = -1;
+ var fmt = ReadOnlySpan<byte>.Empty;
+ while (pos + 8 <= file.Length)
+ {
+ var id = file.Slice(pos, 4);
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ int body = pos + 8;
+ if (size < 0 || body > file.Length) return null;
+ if (id.SequenceEqual("fmt "u8) && size >= 16 && body + 16 <= file.Length)
+ {
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body, 2));
+ channels = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 2, 2));
+ rate = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body + 4, 4));
+ blockAlign = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 12, 2));
+ bits = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 14, 2));
+ fmt = file.Slice(body, Math.Min(size, file.Length - body));
+ if (format == FormatExtensible && size >= 26 && body + 26 <= file.Length)
+ format = BinaryPrimitives.ReadUInt16LittleEndian(file.Slice(body + 24, 2));
+ }
+ else if (id.SequenceEqual("fact"u8) && size >= 4 && body + 4 <= file.Length)
+ factFrames = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(body, 4));
+ else if (id.SequenceEqual("data"u8))
+ {
+ if (channels < 1 || rate < 1) return null;
+ var data = file.Slice(body, Math.Min(size, file.Length - body));
+ // fact (sample count) usually follows fmt, but may come after data: look ahead for it.
+ if (factFrames < 0 && FindFact(file, body + size + (size & 1)) is { } later) factFrames = later;
+ var pcm = format == FormatMsAdpcm ? MsAdpcm(data, fmt, channels, blockAlign) : Decode(data, format, channels, bits);
+ if (pcm != null && factFrames >= 0 && factFrames < pcm.Length) pcm = pcm[..factFrames];
+ return pcm is null ? null : new PcmClip(pcm, rate);
+ }
+ pos = body + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static int? FindFact(ReadOnlySpan<byte> file, int pos)
+ {
+ while (pos + 8 <= file.Length)
+ {
+ int size = BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 4, 4));
+ if (size < 0) return null;
+ if (file.Slice(pos, 4).SequenceEqual("fact"u8) && size >= 4 && pos + 12 <= file.Length)
+ return BinaryPrimitives.ReadInt32LittleEndian(file.Slice(pos + 8, 4));
+ pos += 8 + size + (size & 1);
+ }
+ return null;
+ }
+
+ private static readonly int[] AdpcmAdaptation = [230, 230, 230, 230, 307, 409, 512, 614, 768, 614, 512, 409, 307, 230, 230, 230];
+
+ /// <summary>Microsoft ADPCM: per block and channel a predictor index, delta, and two history samples (output
+ /// oldest first), then 4-bit codes, high nibble first, channels interleaved per nibble. Coefficient pairs come from
+ /// the fmt extension (cbSize, samples per block, count, pairs). A trailing partial block decodes what it holds.</summary>
+ private static short[]? MsAdpcm(ReadOnlySpan<byte> data, ReadOnlySpan<byte> fmt, int channels, int blockAlign)
+ {
+ if (channels > 2 || fmt.Length < 22 || blockAlign < 7 * channels) return null;
+ int count = BinaryPrimitives.ReadUInt16LittleEndian(fmt.Slice(20, 2));
+ if (count < 1 || fmt.Length < 22 + 4 * count) return null;
+ var coef = new (int C1, int C2)[count];
+ for (int i = 0; i < count; i++)
+ coef[i] = (BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(22 + 4 * i, 2)), BinaryPrimitives.ReadInt16LittleEndian(fmt.Slice(24 + 4 * i, 2)));
+ var mono = new System.Collections.Generic.List<short>();
+ Span<int> c1 = stackalloc int[2], c2 = stackalloc int[2], delta = stackalloc int[2], s1 = stackalloc int[2], s2 = stackalloc int[2],
+ frame = stackalloc int[2];
+ for (int start = 0; start + 7 * channels <= data.Length; start += blockAlign)
+ {
+ var block = data.Slice(start, Math.Min(blockAlign, data.Length - start));
+ int p = 0;
+ for (int c = 0; c < channels; c++)
+ {
+ int index = block[p++];
+ if (index >= count) return null;
+ (c1[c], c2[c]) = coef[index];
+ }
+ for (int c = 0; c < channels; c++, p += 2) delta[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s1[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ for (int c = 0; c < channels; c++, p += 2) s2[c] = BinaryPrimitives.ReadInt16LittleEndian(block.Slice(p, 2));
+ mono.Add(Mix(s2, channels));
+ mono.Add(Mix(s1, channels));
+ int ch = 0;
+ for (; p < block.Length; p++)
+ for (int shift = 4; shift >= 0; shift -= 4)
+ {
+ int code = (block[p] >> shift) & 0xF;
+ int signed = code >= 8 ? code - 16 : code;
+ int predicted = ((s1[ch] * c1[ch]) + (s2[ch] * c2[ch])) >> 8;
+ int sample = Math.Clamp(predicted + signed * delta[ch], short.MinValue, short.MaxValue);
+ (s2[ch], s1[ch]) = (s1[ch], sample);
+ delta[ch] = Math.Max(16, (AdpcmAdaptation[code] * delta[ch]) >> 8);
+ frame[ch] = sample;
+ if (++ch == channels)
+ {
+ mono.Add(Mix(frame, channels));
+ ch = 0;
+ }
+ }
+ }
+ return [.. mono];
+ }
+
+ private static short Mix(ReadOnlySpan<int> frame, int channels) => (short)(channels == 1 ? frame[0] : (frame[0] + frame[1]) / 2);
+
+ private static short[]? Decode(ReadOnlySpan<byte> data, int format, int channels, int bits)
+ {
+ int bytes = bits / 8;
+ bool supported = (format == FormatPcm && bits is 8 or 16 or 24) || (format == FormatFloat && bits == 32);
+ if (!supported) return null;
+ int frames = data.Length / (bytes * channels);
+ var pcm = new short[frames];
+ for (int f = 0; f < frames; f++)
+ {
+ if (bits == 8)
+ {
+ int sum8 = 0;
+ for (int c = 0; c < channels; c++) sum8 += (data[f * channels + c] - 128) << 8; // unsigned, 128 = 0
+ pcm[f] = (short)(sum8 / channels);
+ continue;
+ }
+ if (bits == 16)
+ {
+ int sum = 0;
+ for (int c = 0; c < channels; c++)
+ sum += BinaryPrimitives.ReadInt16LittleEndian(data.Slice((f * channels + c) * 2, 2));
+ pcm[f] = (short)(sum / channels);
+ continue;
+ }
+ float mix = 0f;
+ for (int c = 0; c < channels; c++)
+ {
+ var s = data.Slice((f * channels + c) * bytes, bytes);
+ mix += bits == 24
+ ? ((s[0] | s[1] << 8 | (sbyte)s[2] << 16) / 8388608f)
+ : BinaryPrimitives.ReadSingleLittleEndian(s);
+ }
+ pcm[f] = Pcm.ToShort(mix / channels);
+ }
+ return pcm;
+ }
+}