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:
+55
-24
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user