aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/src/Godosa.Core/Audio/Synth.cs
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
committergodosa <godosa@godosa.eu>2026-10-06 23:39:36 +0200
commit39066900773e7857faf2d02e7ed51b71d219d97d (patch)
treeffab8e4ddd971626776c3b1f4ce1da2c186c2187 /src/Godosa.Core/Audio/Synth.cs
downloadgodosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.tar.gz
godosa-engine-39066900773e7857faf2d02e7ed51b71d219d97d.zip
godosa-engine: initial public history
Diffstat (limited to 'src/Godosa.Core/Audio/Synth.cs')
-rw-r--r--src/Godosa.Core/Audio/Synth.cs189
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;
+ }
+}