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:
2026-03-27 19:38:58 +01:00
co-authored by Claude Opus 4.6
parent 494385b528
commit 6b2db48339
14 changed files with 824 additions and 255 deletions
+55 -24
View File
@@ -8,64 +8,95 @@ func EMAAlpha(tau float64, sampleRate int) float64 {
return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate)))
}
// Layer combines an oscillator with EMA amplitude smoothing for one traffic class.
// Layer combines an oscillator with ADSR envelope, LFO modulation,
// and EMA amplitude smoothing for one traffic class.
type Layer struct {
Config FreqConfig
Osc *Oscillator
currentAmp float64
targetAmp float64
alpha float64 // EMA coefficient
env *Envelope
pitchLFO *LFO
tremoloLFO *LFO
seen bool // whether this class has ever had count > 0
whisper float64 // whisper floor amplitude (D-08)
}
// NewLayer creates a Layer for the given config using the specified sample rate and EMA time constant (tau in seconds).
// If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time.
// LFO and ADSR parameters are derived from the protocol group and bursty flag.
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
// Select envelope params based on bursty flag
envParams := SustainedEnvParams
if cfg.Bursty {
envParams = BurstyEnvParams
}
// Get LFO config for this protocol group
lfoCfg := LFOConfigForGroup(cfg.Group)
return &Layer{
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
alpha: EMAAlpha(tau, sampleRate),
whisper: WhisperFloor,
Config: cfg,
Osc: NewOscillator(cfg.BaseHz, sampleRate),
env: NewEnvelope(envParams, sampleRate, tau),
pitchLFO: NewLFO(lfoCfg.PitchRate, lfoCfg.PitchDepth, sampleRate),
tremoloLFO: NewLFO(lfoCfg.TremoloRate, lfoCfg.TremoloDepth, sampleRate),
whisper: WhisperFloor,
}
}
// UpdateTarget sets the target amplitude from a packet count and max count across all classes.
// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate.
// UpdateTarget sets the envelope state and traffic rate from packet counts.
// Triggers the envelope on first appearance, releases when count drops to 0.
func (l *Layer) UpdateTarget(count int64, maxCount int64) {
if count > 0 {
l.seen = true
}
if !l.seen {
l.targetAmp = 0.0
return
if !l.seen {
l.seen = true
l.env.Trigger()
}
// Re-trigger if we were in release/idle
if l.env.State() == EnvRelease || l.env.State() == EnvIdle {
l.env.Trigger()
}
} else if l.seen && count == 0 {
l.env.Release()
}
// Update traffic rate for sustain modulation
normalizedRate := 0.0
if maxCount > 0 {
normalizedRate = float64(count) / float64(maxCount)
}
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate
l.env.SetTrafficRate(normalizedRate)
}
// AdvanceSample renders one sample and advances the EMA amplitude toward target.
// Returns the raw mono sample (before pan/gain).
// AdvanceSample renders one sample with LFO modulation and ADSR envelope.
func (l *Layer) AdvanceSample() float64 {
// Pitch modulation: LFO shifts frequency by a few cents
pitchMod := l.pitchLFO.Advance()
l.Osc.freq = PitchLFOFreq(l.Config.BaseHz, pitchMod)
// Generate waveform sample
sample := l.Osc.Advance(l.Config.Harmonics)
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
return sample * l.currentAmp
// Apply ADSR envelope
envAmp := l.env.Advance()
// Tremolo modulation: LFO modulates amplitude
tremoloMod := 1.0 + l.tremoloLFO.Advance() // [1-depth, 1+depth]
return sample * envAmp * tremoloMod
}
// CurrentAmp returns the current amplitude (for testing).
// CurrentAmp returns the current envelope level (for testing).
func (l *Layer) CurrentAmp() float64 {
return l.currentAmp
return l.env.Level()
}
// TargetAmp returns the target amplitude (for testing).
// TargetAmp returns the current traffic amplitude in the envelope (for testing).
func (l *Layer) TargetAmp() float64 {
return l.targetAmp
return l.env.trafficAmp
}
// Seen returns whether this layer has ever received traffic (for testing).