aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Audio
diff options
context:
space:
mode:
Diffstat (limited to 'src/Godosa.Core/Audio')
-rw-r--r--src/Godosa.Core/Audio/AudioTypes.cs45
-rw-r--r--src/Godosa.Core/Audio/BiquadFilter.cs37
-rw-r--r--src/Godosa.Core/Audio/Mp3.cs133
-rw-r--r--src/Godosa.Core/Audio/Pcm.cs24
-rw-r--r--src/Godosa.Core/Audio/SignalStats.cs51
-rw-r--r--src/Godosa.Core/Audio/Synth.cs189
-rw-r--r--src/Godosa.Core/Audio/WavReader.cs157
7 files changed, 636 insertions, 0 deletions
diff --git a/src/Godosa.Core/Audio/AudioTypes.cs b/src/Godosa.Core/Audio/AudioTypes.cs
new file mode 100644
index 0000000..1ef0730
--- /dev/null
+++ b/src/Godosa.Core/Audio/AudioTypes.cs
@@ -0,0 +1,45 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>An endless stereo source the audio thread pulls blocks from.</summary>
+public interface IStereoStream
+{
+ /// <summary>Fills <paramref name="frames"/> interleaved L/R frames, −1…1.</summary>
+ void Render(float[] interleaved, int frames);
+}
+
+/// <summary>Headphone 3D (binaural). Auto lets OpenAL Soft decide from the output device.</summary>
+public enum HrtfMode { Auto, On, Off }
+
+/// <summary>Why the game is (not) paused, as far as sound cares.</summary>
+public enum AudioPauseState { Running, Menu, Inactive }
+
+/// <summary>The game's mix policy, read by the audio thread every update (≈ 10 ms).
+/// Channels are the game's own small ints (cast its enum).</summary>
+public interface IAudioMix
+{
+ HrtfMode Hrtf { get; }
+ float ChannelGain(int channel, AudioPauseState state);
+ /// <summary>True if sounds on this channel pause with the game world.</summary>
+ bool Pausable(int channel);
+ /// <summary>True if the game world (pausable channels) is paused in this state.</summary>
+ bool WorldPaused(AudioPauseState state);
+ /// <summary>The channel's name in debug logs (default: its number).</summary>
+ string ChannelName(int channel) => channel.ToString(System.Globalization.CultureInfo.InvariantCulture);
+}
+
+/// <summary>EFX standard reverb parameters (OpenAL Soft preset values, efx-presets.h).</summary>
+public readonly record struct ReverbPreset(
+ float Density, float Diffusion, float Gain, float GainHF, float DecayTime, float DecayHFRatio,
+ float ReflectionsGain, float ReflectionsDelay, float LateReverbGain, float LateReverbDelay,
+ float AirAbsorptionGainHF, float RoomRolloffFactor, bool DecayHFLimit)
+{
+ public static readonly ReverbPreset Forest = new(1f, 0.3f, 0.3162f, 0.0224f, 1.49f, 0.54f,
+ 0.0525f, 0.162f, 0.7682f, 0.088f, 0.9943f, 0f, false);
+
+ public static readonly ReverbPreset Plain = new(1f, 0.21f, 0.3162f, 0.1f, 1.49f, 0.5f,
+ 0.0585f, 0.179f, 0.1089f, 0.1f, 0.9943f, 0f, true);
+
+ /// <summary>EFX_REVERB_PRESET_CAVE: long, bright, dense.</summary>
+ public static readonly ReverbPreset Cave = new(1f, 1f, 0.3162f, 1f, 2.91f, 1.3f,
+ 0.5f, 0.015f, 0.7063f, 0.022f, 0.9943f, 0f, false);
+}
diff --git a/src/Godosa.Core/Audio/BiquadFilter.cs b/src/Godosa.Core/Audio/BiquadFilter.cs
new file mode 100644
index 0000000..d0c0f5e
--- /dev/null
+++ b/src/Godosa.Core/Audio/BiquadFilter.cs
@@ -0,0 +1,37 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+public enum FilterKind { LowPass, HighPass, BandPass }
+
+/// <summary>Stateful RBJ-cookbook biquad (transposed direct form II): keeps its state between
+/// calls, so a stream filters block after block without seams.</summary>
+public sealed class BiquadFilter
+{
+ private float _b0, _b1, _b2, _a1, _a2, _z1, _z2;
+
+ public void Set(FilterKind kind, float hz, float q) => SetAt(Pcm.SampleRate, kind, hz, q);
+
+ /// <summary>For a filter run at another rate (e.g. a control-rate modulator).</summary>
+ public void SetAt(float sampleRate, FilterKind kind, float hz, float q)
+ {
+ float f = Math.Clamp(hz, 1f, sampleRate * 0.45f);
+ float w = 2f * MathF.PI * f / sampleRate, cos = MathF.Cos(w), alpha = MathF.Sin(w) / (2f * q);
+ float a0 = 1f + alpha;
+ var (b0, b1, b2) = kind switch
+ {
+ FilterKind.LowPass => ((1f - cos) / 2f, 1f - cos, (1f - cos) / 2f),
+ FilterKind.HighPass => ((1f + cos) / 2f, -(1f + cos), (1f + cos) / 2f),
+ _ => (alpha, 0f, -alpha), // band-pass, 0 dB peak
+ };
+ (_b0, _b1, _b2, _a1, _a2) = (b0 / a0, b1 / a0, b2 / a0, -2f * cos / a0, (1f - alpha) / a0);
+ }
+
+ public float Process(float x)
+ {
+ float y = _b0 * x + _z1;
+ _z1 = _b1 * x - _a1 * y + _z2;
+ _z2 = _b2 * x - _a2 * y;
+ return y;
+ }
+}
diff --git a/src/Godosa.Core/Audio/Mp3.cs b/src/Godosa.Core/Audio/Mp3.cs
new file mode 100644
index 0000000..8ca1b57
--- /dev/null
+++ b/src/Godosa.Core/Audio/Mp3.cs
@@ -0,0 +1,133 @@
+using System;
+using System.IO;
+using NLayer;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Whole MP3 files decoded at once (NLayer), downmixed to mono at the file's rate like
+/// <see cref="WavReader"/>: for positioned sounds and loop emitters. Undecodable → null.</summary>
+public static class Mp3Reader
+{
+ /// <summary>Length in seconds from the frame headers (NLayer's scan, no decoding: ~1/60 of <see cref="Read"/>'s cost),
+ /// so a caller can time a sound and decode it elsewhere. Undecodable → null.</summary>
+ public static double? Seconds(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var samples = mp3.Length / (sizeof(float) * Math.Max(1, mp3.Channels));
+ return samples > 0 && mp3.SampleRate > 0 ? samples / (double)mp3.SampleRate : null;
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+
+ public static PcmClip? Read(byte[] file)
+ {
+ try
+ {
+ using var mp3 = new MpegFile(new MemoryStream(file, writable: false));
+ var channels = Math.Max(1, mp3.Channels);
+ var samples = new System.Collections.Generic.List<float>();
+ var block = new float[4096 * channels];
+ int read;
+ while ((read = mp3.ReadSamples(block, 0, block.Length)) > 0)
+ samples.AddRange(block.AsSpan(0, read - read % channels).ToArray());
+ return samples.Count == 0 ? null : new PcmClip(Pcm.Downmix([.. samples], channels), mp3.SampleRate);
+ }
+ catch (Exception e) when (e is InvalidDataException or IOException or InvalidOperationException
+ or ArgumentException or IndexOutOfRangeException or NullReferenceException)
+ {
+ return null;
+ }
+ }
+}
+
+/// <summary>An MP3 as a stereo stream (NLayer decode on the audio thread; lifted from an earlier game of the same author): mono is duplicated,
+/// other sample rates are resampled linearly to <see cref="Pcm.SampleRate"/>; a once-only stream goes silent and reports
+/// <see cref="Finished"/>. For music and speech.</summary>
+public sealed class Mp3Stream : IStereoStream, IDisposable
+{
+ private readonly MpegFile _file;
+ private readonly bool _loop;
+ private readonly int _channels;
+ private readonly double _step;
+ private float[] _buffer = [];
+ private int _available, _read;
+ private double _position;
+ private volatile bool _finished;
+
+ public Mp3Stream(byte[] data, bool loop)
+ {
+ _file = new MpegFile(new MemoryStream(data, writable: false));
+ _loop = loop;
+ _channels = Math.Max(1, _file.Channels);
+ _step = _file.SampleRate / (double)Pcm.SampleRate;
+ }
+
+ public bool Finished => _finished;
+
+ public void Render(float[] interleaved, int frames)
+ {
+ for (var f = 0; f < frames; f++)
+ {
+ if (!Frame(out var left, out var right))
+ {
+ Array.Clear(interleaved, 2 * f, 2 * (frames - f));
+ return;
+ }
+ interleaved[2 * f] = left;
+ interleaved[2 * f + 1] = right;
+ _position += _step;
+ }
+ }
+
+ private bool Frame(out float left, out float right)
+ {
+ left = right = 0f;
+ var index = (int)_position;
+ while (index >= _available)
+ {
+ if (!Fill())
+ return false;
+ index = (int)_position;
+ }
+ var next = Math.Min(index + 1, _available - 1);
+ var t = (float)(_position - index);
+ left = Lerp(_buffer[index * _channels], _buffer[next * _channels], t);
+ right = _channels > 1 ? Lerp(_buffer[index * _channels + 1], _buffer[next * _channels + 1], t) : left;
+ return true;
+ }
+
+ private static float Lerp(float a, float b, float t) => a + (b - a) * t;
+
+ private bool Fill()
+ {
+ if (_finished)
+ return false;
+ _position -= _available;
+ if (_buffer.Length == 0)
+ _buffer = new float[4096 * _channels];
+ var read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ if (read <= 0 && _loop && _read > 0)
+ {
+ _file.Position = 0;
+ _read = 0;
+ read = _file.ReadSamples(_buffer, 0, _buffer.Length);
+ }
+ if (read <= 0)
+ {
+ _finished = true;
+ _available = 0;
+ return false;
+ }
+ _read += read;
+ _available = read / _channels;
+ return true;
+ }
+
+ public void Dispose() => _file.Dispose();
+}
diff --git a/src/Godosa.Core/Audio/Pcm.cs b/src/Godosa.Core/Audio/Pcm.cs
new file mode 100644
index 0000000..2af554a
--- /dev/null
+++ b/src/Godosa.Core/Audio/Pcm.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>16-bit mono PCM helpers.</summary>
+public static class Pcm
+{
+ public const int SampleRate = 44100;
+
+ /// <summary>Interleaved float frames (e.g. decoded OGG) → mono 16-bit.</summary>
+ public static short[] Downmix(float[] interleaved, int channels)
+ {
+ var pcm = new short[interleaved.Length / channels];
+ for (int f = 0; f < pcm.Length; f++)
+ {
+ float sum = 0f;
+ for (int c = 0; c < channels; c++) sum += interleaved[f * channels + c];
+ pcm[f] = ToShort(sum / channels);
+ }
+ return pcm;
+ }
+
+ public static short ToShort(float sample) => (short)Math.Clamp(MathF.Round(sample * short.MaxValue), -short.MaxValue, short.MaxValue);
+}
diff --git a/src/Godosa.Core/Audio/SignalStats.cs b/src/Godosa.Core/Audio/SignalStats.cs
new file mode 100644
index 0000000..34f30df
--- /dev/null
+++ b/src/Godosa.Core/Audio/SignalStats.cs
@@ -0,0 +1,51 @@
+namespace Godosa.Core.Audio;
+
+/// <summary>Signal measurements for audio tests and reports: zero crossings, RMS, spectral centroid.</summary>
+public static class SignalStats
+{
+ public static int ZeroCrossings(float[] x, int from = 0, int to = -1)
+ {
+ if (to < 0) to = x.Length;
+ int n = 0;
+ for (int i = from + 1; i < to; i++) if ((x[i - 1] < 0) != (x[i] < 0)) n++;
+ return n;
+ }
+
+ public static float Rms(float[] x, int from = 0)
+ {
+ double sum = 0;
+ for (int i = from; i < x.Length; i++) sum += x[i] * x[i];
+ return (float)Math.Sqrt(sum / (x.Length - from));
+ }
+
+ /// <summary>Power-weighted mean frequency (where the energy is) of the loudest 2048-sample
+ /// window, naive DFT. Magnitude weighting over-counts faint broadband tails.</summary>
+ public static float Centroid(short[] pcm, int rate = Pcm.SampleRate)
+ {
+ const int n = 2048;
+ int best = 0;
+ double bestEnergy = -1;
+ for (int start = 0; start + n <= Math.Max(pcm.Length, n); start += n / 2)
+ {
+ double e = 0;
+ for (int i = start; i < Math.Min(pcm.Length, start + n); i++) e += (double)pcm[i] * pcm[i];
+ if (e > bestEnergy) (bestEnergy, best) = (e, start);
+ }
+ double weighted = 0, total = 0;
+ for (int k = 1; k < n / 2; k++)
+ {
+ double re = 0, im = 0;
+ for (int i = 0; i < n; i++)
+ {
+ double v = best + i < pcm.Length ? pcm[best + i] : 0;
+ double a = 2 * Math.PI * k * i / n;
+ re += v * Math.Cos(a);
+ im -= v * Math.Sin(a);
+ }
+ double power = re * re + im * im;
+ weighted += power * k * rate / (double)n;
+ total += power;
+ }
+ return (float)(weighted / total);
+ }
+}
diff --git a/src/Godosa.Core/Audio/Synth.cs b/src/Godosa.Core/Audio/Synth.cs
new file mode 100644
index 0000000..cb663a9
--- /dev/null
+++ b/src/Godosa.Core/Audio/Synth.cs
@@ -0,0 +1,189 @@
+using System;
+
+namespace Godosa.Core.Audio;
+
+/// <summary>Deterministic xorshift32 for sound variants and seeded songs. The seed is mixed first
+/// (murmur3 fmix32): raw xorshift's first draws barely change between nearby seeds.</summary>
+public sealed class SynthRandom(uint seed)
+{
+ private uint _state = Mix(seed) is var s && s != 0 ? s : 0x9E3779B9u;
+
+ public static uint Mix(uint h)
+ {
+ h ^= h >> 16;
+ h *= 0x85EBCA6Bu;
+ h ^= h >> 13;
+ h *= 0xC2B2AE35u;
+ h ^= h >> 16;
+ return h;
+ }
+
+ public float Next01()
+ {
+ _state ^= _state << 13;
+ _state ^= _state >> 17;
+ _state ^= _state << 5;
+ return (_state >> 8) / 16777216f;
+ }
+
+ public float Range(float min, float max) => min + (max - min) * Next01();
+
+ /// <summary>Uniform in [−1, 1).</summary>
+ public float Signed() => Next01() * 2f - 1f;
+
+ /// <summary>FNV-1a: stable across runs (string.GetHashCode isn't).</summary>
+ public static uint Hash(string text)
+ {
+ uint h = 2166136261;
+ foreach (char c in text) h = (h ^ c) * 16777619;
+ return h;
+ }
+}
+
+public enum WaveShape { Sine, Saw, Square, Triangle }
+
+/// <summary>Placeholder sound synthesis (audio spec §3): float buffers at 44.1 kHz,
+/// oscillators with exponential sweeps, noise, envelopes, swept RBJ biquads.</summary>
+public static class Synth
+{
+ public const int Rate = Pcm.SampleRate;
+ private const int FilterBlock = 32;
+
+ public static int Samples(float seconds) => Math.Max(1, (int)(seconds * Rate));
+
+ public static float[] Sine(float seconds, float f0, float? f1 = null) => Wave(WaveShape.Sine, seconds, f0, f1 ?? f0);
+
+ /// <summary>Oscillator whose pitch sweeps exponentially from f0 to f1 over the buffer.</summary>
+ public static float[] Wave(WaveShape shape, float seconds, float f0, float f1)
+ {
+ var x = new float[Samples(seconds)];
+ double phase = 0, ratio = f1 / f0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ double f = f0 * Math.Pow(ratio, i / (double)x.Length);
+ float p = (float)(phase - Math.Floor(phase));
+ x[i] = shape switch
+ {
+ WaveShape.Saw => 2f * p - 1f,
+ WaveShape.Square => p < 0.5f ? 1f : -1f,
+ WaveShape.Triangle => 1f - 4f * MathF.Abs(p - 0.5f),
+ _ => MathF.Sin(2f * MathF.PI * p),
+ };
+ phase += f / Rate;
+ }
+ return x;
+ }
+
+ /// <summary>FM: carrier f, modulator at f·ratio, index sweeping index0 → index1 (bells, chirps).</summary>
+ public static float[] Fm(float seconds, float carrier, float ratio, float index0, float index1, float? carrierEnd = null)
+ {
+ var x = new float[Samples(seconds)];
+ double pc = 0, pm = 0, cRatio = (carrierEnd ?? carrier) / carrier;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)x.Length;
+ double c = carrier * Math.Pow(cRatio, t);
+ float index = index0 + (index1 - index0) * t;
+ x[i] = (float)Math.Sin(2 * Math.PI * pc + index * Math.Sin(2 * Math.PI * pm));
+ pc += c / Rate;
+ pm += c * ratio / Rate;
+ }
+ return x;
+ }
+
+ public static float[] White(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ for (int i = 0; i < x.Length; i++) x[i] = rng.Signed();
+ return x;
+ }
+
+ /// <summary>Pink-ish noise (Paul Kellet's economy filter): softer, like wind and leaves.</summary>
+ public static float[] Pink(float seconds, SynthRandom rng)
+ {
+ var x = new float[Samples(seconds)];
+ float b0 = 0, b1 = 0, b2 = 0;
+ for (int i = 0; i < x.Length; i++)
+ {
+ float w = rng.Signed();
+ b0 = 0.99765f * b0 + w * 0.0990460f;
+ b1 = 0.96300f * b1 + w * 0.2965164f;
+ b2 = 0.57000f * b2 + w * 1.0526913f;
+ x[i] = (b0 + b1 + b2 + w * 0.1848f) * 0.25f;
+ }
+ return x;
+ }
+
+ /// <summary>Linear attack to 1, then exponential decay with time constant <paramref name="decay"/>.</summary>
+ public static float[] Perc(this float[] x, float attack, float decay)
+ {
+ for (int i = 0; i < x.Length; i++)
+ {
+ float t = i / (float)Rate;
+ x[i] *= t < attack ? t / attack : MathF.Exp(-(t - attack) / decay);
+ }
+ return x;
+ }
+
+ public static float[] Gain(this float[] x, float gain)
+ {
+ for (int i = 0; i < x.Length; i++) x[i] *= gain;
+ return x;
+ }
+
+ /// <summary>Adds <paramref name="y"/> into <paramref name="into"/> at a time offset (clipped to fit).</summary>
+ public static float[] Add(this float[] into, float[] y, float atSeconds = 0f, float gain = 1f)
+ {
+ int offset = (int)(atSeconds * Rate);
+ for (int i = 0; i < y.Length && offset + i < into.Length; i++) into[offset + i] += y[i] * gain;
+ return into;
+ }
+
+ public static float[] LowPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.LowPass, f0, f1 ?? f0, q);
+ public static float[] HighPass(this float[] x, float f0, float? f1 = null, float q = 0.7071f) => Filter(x, FilterKind.HighPass, f0, f1 ?? f0, q);
+ public static float[] BandPass(this float[] x, float f0, float q, float? f1 = null) => Filter(x, FilterKind.BandPass, f0, f1 ?? f0, q);
+
+ /// <summary>Biquad with its cutoff swept exponentially f0 → f1, retuned every 32 samples. In place.</summary>
+ private static float[] Filter(float[] x, FilterKind kind, float f0, float f1, float q)
+ {
+ var filter = new BiquadFilter();
+ for (int i = 0; i < x.Length; i++)
+ {
+ if (i % FilterBlock == 0) filter.Set(kind, f0 * MathF.Pow(f1 / f0, i / (float)x.Length), q);
+ x[i] = filter.Process(x[i]);
+ }
+ return x;
+ }
+
+ /// <summary>Makes a loop seamless: the extra <paramref name="overlapSamples"/> at the end fade
+ /// into the start, so the result wraps like any adjacent pair of samples.</summary>
+ public static float[] Loop(float[] x, int overlapSamples)
+ {
+ int overlap = overlapSamples, n = x.Length - overlap;
+ var y = new float[n];
+ Array.Copy(x, y, n);
+ for (int i = 0; i < overlap; i++)
+ {
+ float t = i / (float)overlap;
+ y[i] = x[i] * t + x[n + i] * (1f - t);
+ }
+ return y;
+ }
+
+ /// <summary>Normalises the peak to <paramref name="level"/> (0–1 FS) and, unless it's a loop,
+ /// fades 2 ms at both ends so starts and stops never click.</summary>
+ public static short[] ToPcm(float[] x, float level, bool fadeEnds = true)
+ {
+ float peak = 0f;
+ foreach (float v in x) peak = MathF.Max(peak, MathF.Abs(v));
+ float scale = peak > 0f ? level / peak : 0f;
+ int fade = fadeEnds ? Math.Min(x.Length / 2, Samples(0.002f)) : 0;
+ var pcm = new short[x.Length];
+ for (int i = 0; i < x.Length; i++)
+ {
+ float env = fade == 0 ? 1f : Math.Min(1f, Math.Min(i, x.Length - 1 - i) / (float)fade);
+ pcm[i] = Pcm.ToShort(x[i] * scale * env);
+ }
+ return pcm;
+ }
+}
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;
+ }
+}