Files
yoloyolo/synth/envelope.go
T
gurixandClaude Opus 4.6 6b2db48339 feat(synth): add LFO modulation, ADSR envelopes, pentatonic tuning, and soft limiter
Replace static EMA-smoothed drones with an evolving ambient soundscape:
- ADSR envelope system with sustained (2s attack, 4s release) and bursty
  (30ms attack, no sustain) modes per protocol group
- LFO pitch wobble and amplitude tremolo with incommensurable rates per
  group (Eno technique) so modulation patterns never repeat
- C major pentatonic frequency tuning (just intonation) — any combination
  of active protocols sounds consonant
- tanh soft limiter on master output prevents clipping
- Sync all documentation: README, PROJECT.md, ARCHITECTURE.md, v1.2
  requirements traceability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:38:58 +01:00

137 lines
3.9 KiB
Go

package synth
// EnvelopeState tracks the current phase of an ADSR envelope.
type EnvelopeState int
const (
EnvIdle EnvelopeState = iota // silent, waiting for trigger
EnvAttack // ramping up to peak
EnvDecay // falling from peak to sustain level
EnvSustain // holding at sustain level (modulated by traffic rate)
EnvRelease // fading out after traffic stops
)
// Envelope is an ADSR envelope generator with exponential curves.
// It wraps the traffic-rate amplitude: the envelope shapes onset/offset,
// while the traffic rate modulates within the sustain phase.
type Envelope struct {
state EnvelopeState
level float64 // current envelope level [0, 1]
attackRate float64 // per-sample (exponential approach)
decayRate float64 // per-sample
sustainLevel float64 // target level during sustain [0, 1]
releaseRate float64 // per-sample
trafficAmp float64 // EMA-smoothed traffic amplitude [0, 1]
emaAlpha float64 // EMA coefficient for traffic smoothing
}
// EnvelopeParams configures ADSR timing.
type EnvelopeParams struct {
AttackSec float64 // seconds to reach peak
DecaySec float64 // seconds from peak to sustain level
SustainLevel float64 // sustain amplitude [0, 1]
ReleaseSec float64 // seconds to fade to silence
}
// Sustained flow envelope: slow, ambient feel.
var SustainedEnvParams = EnvelopeParams{
AttackSec: 2.0,
DecaySec: 1.0,
SustainLevel: 0.85,
ReleaseSec: 4.0,
}
// Bursty protocol envelope: percussive, event-like.
var BurstyEnvParams = EnvelopeParams{
AttackSec: 0.03,
DecaySec: 0.3,
SustainLevel: 0.0,
ReleaseSec: 1.5,
}
// NewEnvelope creates an ADSR envelope for the given params and sample rate.
// tau is the EMA time constant for traffic amplitude smoothing (seconds).
func NewEnvelope(params EnvelopeParams, sampleRate int, tau float64) *Envelope {
sr := float64(sampleRate)
return &Envelope{
state: EnvIdle,
attackRate: 1.0 / (params.AttackSec * sr),
decayRate: 1.0 / (params.DecaySec * sr),
sustainLevel: params.SustainLevel,
releaseRate: 1.0 / (params.ReleaseSec * sr),
emaAlpha: EMAAlpha(tau, sampleRate),
}
}
// Trigger starts the envelope (called when traffic first appears for this class).
func (e *Envelope) Trigger() {
if e.state == EnvIdle || e.state == EnvRelease {
e.state = EnvAttack
}
}
// Release begins the release phase (called when traffic stops).
func (e *Envelope) Release() {
if e.state != EnvIdle {
e.state = EnvRelease
}
}
// SetTrafficRate updates the EMA-smoothed traffic amplitude target.
// rate should be normalized [0, 1] (count / maxCount).
func (e *Envelope) SetTrafficRate(rate float64) {
e.trafficAmp += e.emaAlpha * (rate - e.trafficAmp)
}
// Advance processes one sample and returns the envelope amplitude [0, 1].
func (e *Envelope) Advance() float64 {
switch e.state {
case EnvIdle:
return 0
case EnvAttack:
e.level += e.attackRate * (1.05 - e.level) // overshoot target slightly for exponential feel
if e.level >= 1.0 {
e.level = 1.0
e.state = EnvDecay
}
case EnvDecay:
target := e.sustainLevel
e.level += e.decayRate * (target - e.level)
if e.level-target < 0.001 {
e.level = target
if target > 0 {
e.state = EnvSustain
} else {
// Bursty: sustain=0, go to release
e.state = EnvRelease
}
}
case EnvSustain:
// Modulate sustain level by traffic rate
target := e.sustainLevel * (WhisperFloor + (1.0-WhisperFloor)*e.trafficAmp)
e.level += e.emaAlpha * (target - e.level)
case EnvRelease:
e.level -= e.releaseRate * e.level
if e.level < 0.001 {
e.level = 0
e.state = EnvIdle
}
}
return e.level
}
// State returns the current envelope state (for testing).
func (e *Envelope) State() EnvelopeState {
return e.state
}
// Level returns the current envelope level (for testing).
func (e *Envelope) Level() float64 {
return e.level
}