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>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user