diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:39:36 +0200 |
| commit | 39066900773e7857faf2d02e7ed51b71d219d97d (patch) | |
| tree | ffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Audio | |
| download | godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip | |
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Audio')
| -rw-r--r-- | src/Godosa.Core/Audio/AudioTypes.cs | 45 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/BiquadFilter.cs | 37 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/Mp3.cs | 133 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/Pcm.cs | 24 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/SignalStats.cs | 51 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/Synth.cs | 189 | ||||
| -rw-r--r-- | src/Godosa.Core/Audio/WavReader.cs | 157 |
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; + } +} |
