diff options
Diffstat (limited to 'src/Godosa.Core/Audio/Synth.cs')
| -rw-r--r-- | src/Godosa.Core/Audio/Synth.cs | 189 |
1 files changed, 189 insertions, 0 deletions
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; + } +} |
