package synth import ( "math" "github.com/netsynth/netsynth/classify" ) // OscillatorBank holds synthesis layers, one per TrafficClass in the injected config map. // It consumes WindowSnapshot data and renders stereo PCM frames. type OscillatorBank struct { layers map[classify.TrafficClass]*Layer tau float64 gainPerLayer float64 } // maxTremoloDepth is the highest tremolo depth across all groups. // Used to compute headroom so tremolo doesn't cause clipping. const maxTremoloDepth = 0.20 // NewBank creates an OscillatorBank with one Layer per entry in cfgs. // tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel). // gainPerLayer accounts for tremolo headroom: 1 / (N * (1 + maxTremoloDepth)). func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank { b := &OscillatorBank{ layers: make(map[classify.TrafficClass]*Layer, len(cfgs)), tau: tau, gainPerLayer: 1.0 / (float64(len(cfgs)) * (1.0 + maxTremoloDepth)), } for class, cfg := range cfgs { b.layers[class] = NewLayer(cfg, SampleRate, tau) } return b } // RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow // stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1]. // Each layer gets gain with tremolo headroom. A soft limiter prevents any residual clipping. func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 { // Find max count for normalization var maxCount int64 for _, count := range snap.Counts { if count > maxCount { maxCount = count } } // Update target amplitudes for all layers for class, layer := range b.layers { count := snap.Counts[class] layer.UpdateTarget(count, maxCount) } // Render frames frames := make([][2]float64, SamplesPerWindow) for i := range frames { var sumL, sumR float64 for _, layer := range b.layers { sample := layer.AdvanceSample() gainL, gainR := PanGains(layer.Config.Pan) sumL += sample * b.gainPerLayer * gainL sumR += sample * b.gainPerLayer * gainR } frames[i] = [2]float64{softLimit(sumL), softLimit(sumR)} } return frames } // softLimit applies a tanh-based soft limiter to prevent clipping. // Values within [-0.9, 0.9] pass nearly linearly; beyond that, they compress smoothly. func softLimit(x float64) float64 { return math.Tanh(x) }