2026-03-26 11:58:44 +01:00
|
|
|
package synth
|
|
|
|
|
|
|
|
|
|
import "math"
|
|
|
|
|
|
|
|
|
|
// EMAAlpha computes the per-sample smoothing coefficient for a given time constant.
|
|
|
|
|
// tau=1.0 at SR=44100 gives alpha ~0.0000227 (63% of target reached in 1 second). Per D-07.
|
|
|
|
|
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.
|
|
|
|
|
type Layer struct {
|
|
|
|
|
Config FreqConfig
|
|
|
|
|
Osc *Oscillator
|
|
|
|
|
currentAmp float64
|
|
|
|
|
targetAmp float64
|
|
|
|
|
alpha float64 // EMA coefficient
|
|
|
|
|
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).
|
2026-03-26 17:34:45 +01:00
|
|
|
// If cfg.WaveformType is not WaveformCustom, harmonics are resolved from the preset at construction time.
|
2026-03-26 11:58:44 +01:00
|
|
|
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
|
2026-03-26 17:34:45 +01:00
|
|
|
if cfg.WaveformType != WaveformCustom {
|
|
|
|
|
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
|
|
|
|
|
}
|
2026-03-26 11:58:44 +01:00
|
|
|
return &Layer{
|
|
|
|
|
Config: cfg,
|
|
|
|
|
Osc: NewOscillator(cfg.BaseHz, sampleRate),
|
|
|
|
|
alpha: EMAAlpha(tau, 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.
|
|
|
|
|
func (l *Layer) UpdateTarget(count int64, maxCount int64) {
|
|
|
|
|
if count > 0 {
|
|
|
|
|
l.seen = true
|
|
|
|
|
}
|
|
|
|
|
if !l.seen {
|
|
|
|
|
l.targetAmp = 0.0
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
normalizedRate := 0.0
|
|
|
|
|
if maxCount > 0 {
|
|
|
|
|
normalizedRate = float64(count) / float64(maxCount)
|
|
|
|
|
}
|
|
|
|
|
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AdvanceSample renders one sample and advances the EMA amplitude toward target.
|
|
|
|
|
// Returns the raw mono sample (before pan/gain).
|
|
|
|
|
func (l *Layer) AdvanceSample() float64 {
|
|
|
|
|
sample := l.Osc.Advance(l.Config.Harmonics)
|
|
|
|
|
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
|
|
|
|
|
return sample * l.currentAmp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CurrentAmp returns the current amplitude (for testing).
|
|
|
|
|
func (l *Layer) CurrentAmp() float64 {
|
|
|
|
|
return l.currentAmp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TargetAmp returns the target amplitude (for testing).
|
|
|
|
|
func (l *Layer) TargetAmp() float64 {
|
|
|
|
|
return l.targetAmp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Seen returns whether this layer has ever received traffic (for testing).
|
|
|
|
|
func (l *Layer) Seen() bool {
|
|
|
|
|
return l.seen
|
|
|
|
|
}
|