diff options
Diffstat (limited to 'src/Godosa.Core/Audio/WavReader.cs')
| -rw-r--r-- | src/Godosa.Core/Audio/WavReader.cs | 157 |
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; + } +} |
