- 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
123 lines
2.2 KiB
Go
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
|
|
}
|
|
}
|