package synth import "github.com/netsynth/netsynth/classify" // OscillatorBank holds 11 synthesis layers, one per TrafficClass. // It consumes WindowSnapshot data and renders stereo PCM frames. type OscillatorBank struct { layers map[classify.TrafficClass]*Layer tau float64 } // NewBank creates an OscillatorBank with one Layer per TrafficClass. // tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel). func NewBank(tau float64) *OscillatorBank { b := &OscillatorBank{ layers: make(map[classify.TrafficClass]*Layer, NumLayers), tau: tau, } for _, class := range classify.AllClasses() { cfg := ClassFreqConfigs[class] 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]. // Per D-10: each layer gets GainPerLayer (1/11) so 11 max-amplitude layers sum to 1.0 (no 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 := range classify.AllClasses() { count := snap.Counts[class] b.layers[class].UpdateTarget(count, maxCount) } // Render frames frames := make([][2]float64, SamplesPerWindow) for i := range frames { var sumL, sumR float64 for _, class := range classify.AllClasses() { layer := b.layers[class] sample := layer.AdvanceSample() gainL, gainR := PanGains(layer.Config.Pan) sumL += sample * GainPerLayer * gainL sumR += sample * GainPerLayer * gainR } frames[i] = [2]float64{sumL, sumR} } return frames }