Files
train-game/scripts/synth.gd
2026-08-29 11:44:31 +02:00

164 lines
5.3 KiB
GDScript

extends RefCounted
## Kleiner Synthesizer. Erzeugt PCM-Puffer und daraus AudioStreamWAV.
## Alles wird beim Start berechnet, zur Laufzeit kostet es nichts mehr.
const RATE := 22050
const TABLE_BITS := 11
const TABLE_SIZE := 1 << TABLE_BITS
const TABLE_MASK := TABLE_SIZE - 1
static var _sine: PackedFloat32Array
static func _table() -> PackedFloat32Array:
if _sine.is_empty():
_sine.resize(TABLE_SIZE)
for i in TABLE_SIZE:
_sine[i] = sin(TAU * float(i) / float(TABLE_SIZE))
return _sine
## Sinus über eine Phase in Umdrehungen (0..1). Schneller als sin().
static func osc(phase: float) -> float:
var t := _table()
return t[int(phase * TABLE_SIZE) & TABLE_MASK]
static func buffer(seconds: float) -> PackedFloat32Array:
var b := PackedFloat32Array()
b.resize(int(seconds * RATE))
return b
## Ton mit Obertönen und exponentiellem Abfall.
static func add_tone(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, decay: float, harmonics: Array = [1.0], attack := 0.005,
vibrato := 0.0, vib_hz := 5.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var step := freq / float(RATE)
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env := exp(-t * decay)
if t < attack:
env *= t / attack
var ph := float(i) * step
if vibrato > 0.0:
# Integrierte Phasenauslenkung. Die Phase selbst zu skalieren
# würde die Tonhöhe über die Zeit davonlaufen lassen.
ph += vibrato * freq / (TAU * vib_hz) * (1.0 + osc(t * vib_hz - 0.25))
var v := 0.0
var h := 1.0
for a in harmonics:
v += float(a) * osc(ph * h)
h += 1.0
buf[idx] += v * env * amp
## Weiches Pad: langsam an, langsam aus.
static func add_pad(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, fade: float) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var step := freq / float(RATE)
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env: float = clampf(t / fade, 0.0, 1.0) * clampf((dur - t) / fade, 0.0, 1.0)
env = env * env * (3.0 - 2.0 * env)
var p := float(i) * step
var v := osc(p) + 0.30 * osc(p * 2.0) + 0.09 * osc(p * 3.0)
# Leichtes Schweben durch minimale Verstimmung
v += 0.26 * osc(p * 1.003)
buf[idx] += v * env * amp * 0.5
## Gefiltertes Rauschen mit Hüllkurve.
static func add_noise(buf: PackedFloat32Array, start: float, dur: float, amp: float,
decay: float, lp: float, rng: RandomNumberGenerator, attack := 0.002) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var y := 0.0
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env := exp(-t * decay)
if t < attack:
env *= t / attack
y += (rng.randf_range(-1.0, 1.0) - y) * lp
buf[idx] += y * env * amp
## Ton mit echtem Frequenzwobbeln. Die Phase wird aufsummiert, nicht
## skaliert - nur so bleibt die Tonhöhe da, wo sie hingehört.
static func add_wobble(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, depth: float, rate: float, harm2 := 0.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var ph := 0.0
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
ph += freq * (1.0 + depth * osc(t * rate)) / float(RATE)
buf[idx] += (osc(ph) + harm2 * osc(ph * 2.0)) * amp
## Rauschen durch einen schmalen Resonanzfilter - klingt metallisch,
## ohne die Härte eines reinen Sinus.
static func add_resonant_noise(buf: PackedFloat32Array, start: float, dur: float,
freq: float, r: float, amp: float, rng: RandomNumberGenerator,
decay := 0.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var b1 := 2.0 * r * cos(TAU * freq / float(RATE))
var b2 := -r * r
var y1 := 0.0
var y2 := 0.0
var gain := (1.0 - r) * 1.6
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var y := gain * rng.randf_range(-1.0, 1.0) + b1 * y1 + b2 * y2
y2 = y1
y1 = y
var env := 1.0 if decay <= 0.0 else exp(-float(i) / float(RATE) * decay)
buf[idx] += y * amp * env
## Legt den Ausklang auf den Anfang, damit die Schleife nahtlos wird.
static func seamless(buf: PackedFloat32Array, loop_len: float) -> PackedFloat32Array:
var n := int(loop_len * RATE)
var tail: int = mini(buf.size() - n, n)
var out := buf.slice(0, n)
for i in tail:
var w := 1.0 - float(i) / float(tail)
out[i] += buf[n + i] * w
return out
## Begrenzt sanft und wandelt in rohes PCM. Läuft auch in einem Thread.
static func to_pcm(buf: PackedFloat32Array) -> PackedByteArray:
var peak := 0.0
for v in buf:
peak = maxf(peak, absf(v))
var gain: float = 0.92 / peak if peak > 0.001 else 1.0
var data := PackedByteArray()
data.resize(buf.size() * 2)
for i in buf.size():
var v: float = clampf(buf[i] * gain, -1.0, 1.0)
data.encode_s16(i * 2, int(v * 32767.0))
return data
## Baut den abspielbaren Stream. Nur im Hauptthread aufrufen.
static func to_stream(pcm: PackedByteArray, loop := false) -> AudioStreamWAV:
var w := AudioStreamWAV.new()
w.format = AudioStreamWAV.FORMAT_16_BITS
w.mix_rate = RATE
w.stereo = false
w.data = pcm
if loop:
w.loop_mode = AudioStreamWAV.LOOP_FORWARD
w.loop_begin = 0
w.loop_end = pcm.size() / 2
return w