Files
agent-tools/sfx-maker/sfx/synth.go
claude e7ac31b5c8 sfx-maker: sfxc - sfxr-style .sfx text presets -> 16-bit WAV synthesis
- waves: square (duty), saw, sine, triangle, pitched noise (seeded, deterministic)
- envelope attack/sustain/decay, freq slide, vibrato, arpeggio jump,
  one-pole low/high-pass filters, clamped output
- presets blip/coin/explosion/hurt/jump/laser/powerup with seeded variants;
  preset writes editable .sfx or renders .wav directly
- go tests: parse/validate, determinism, per-preset RMS, WAV header roundtrip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmdG9GqfSWCzts7AkDwRDh
2026-07-14 07:08:04 +02:00

123 lines
2.2 KiB
Go

package sfx
import (
"math"
"math/rand"
)
// Render synthesizes the effect into float64 samples in [-1, 1].
func Render(p *Params) []float64 {
sr := float64(p.SampleRate)
n := int(p.Duration() * sr)
out := make([]float64, n)
rng := rand.New(rand.NewSource(p.Seed))
phase := 0.0
noiseVal := 0.0
noiseCounter := 0.0
// one-pole filter states
lpState := 0.0
hpState := 0.0
hpPrevIn := 0.0
dt := 1 / sr
lpAlpha := 0.0
if p.LowPass > 0 {
rc := 1 / (2 * math.Pi * p.LowPass)
lpAlpha = dt / (rc + dt)
}
hpAlpha := 0.0
if p.HighPass > 0 {
rc := 1 / (2 * math.Pi * p.HighPass)
hpAlpha = rc / (rc + dt)
}
for i := 0; i < n; i++ {
t := float64(i) / sr
f := p.Freq + p.FreqSlide*t
if p.ArpFactor != 0 && p.ArpTime > 0 && t >= p.ArpTime {
f *= p.ArpFactor
}
if p.VibratoDepth > 0 && p.VibratoRate > 0 {
f += p.VibratoDepth * math.Sin(2*math.Pi*p.VibratoRate*t)
}
if f < p.FreqMin {
f = p.FreqMin
}
var s float64
if p.Wave == "noise" {
// pitched noise: new random value f*4 times per second
noiseCounter += f * 4 * dt
if noiseCounter >= 1 || i == 0 {
noiseCounter = math.Mod(noiseCounter, 1)
noiseVal = rng.Float64()*2 - 1
}
s = noiseVal
} else {
phase += f * dt
ph := math.Mod(phase, 1)
switch p.Wave {
case "square":
if ph < p.Duty {
s = 1
} else {
s = -1
}
case "saw":
s = 2*ph - 1
case "triangle":
if ph < 0.5 {
s = 4*ph - 1
} else {
s = 3 - 4*ph
}
case "sine":
s = math.Sin(2 * math.Pi * ph)
}
}
s *= envelope(p, t)
if lpAlpha > 0 {
lpState += lpAlpha * (s - lpState)
s = lpState
}
if hpAlpha > 0 {
hpState = hpAlpha * (hpState + s - hpPrevIn)
hpPrevIn = s
s = hpState
}
s *= p.Volume
if s > 1 {
s = 1
} else if s < -1 {
s = -1
}
out[i] = s
}
return out
}
// envelope is a linear attack / sustain / decay gain in 0..1.
func envelope(p *Params, t float64) float64 {
switch {
case t < p.Attack:
return t / p.Attack
case t < p.Attack+p.Sustain:
return 1
default:
d := t - p.Attack - p.Sustain
if p.Decay <= 0 {
return 0
}
g := 1 - d/p.Decay
if g < 0 {
g = 0
}
return g
}
}