From 23dcfdba1d29d0698c47513baec9214efc63e0ef Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 12:03:06 +0100 Subject: [PATCH] feat(02-02): OscillatorBank multi-layer rendering from WindowSnapshot - NewBank creates 11 layers from ClassFreqConfigs, one per TrafficClass - RenderWindow transforms WindowSnapshot into SamplesPerWindow stereo frames - GainPerLayer (1/11) applied per layer guarantees no clipping with all layers at max - PanGains applied per layer for constant-power stereo positioning - EMA amplitude smoothing provides temporal convergence across windows - 7 bank tests: layer count, output length, silence, non-zero, no-clip, stereo pan, EMA convergence --- synth/bank.go | 58 ++++++++++++++++ synth/bank_test.go | 160 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 synth/bank.go create mode 100644 synth/bank_test.go diff --git a/synth/bank.go b/synth/bank.go new file mode 100644 index 0000000..4bf80e0 --- /dev/null +++ b/synth/bank.go @@ -0,0 +1,58 @@ +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 +} diff --git a/synth/bank_test.go b/synth/bank_test.go new file mode 100644 index 0000000..753938e --- /dev/null +++ b/synth/bank_test.go @@ -0,0 +1,160 @@ +package synth + +import ( + "math" + "testing" + + "github.com/netsynth/netsynth/classify" +) + +func TestNewBankHas11Layers(t *testing.T) { + b := NewBank(1.0) + if len(b.layers) != 11 { + t.Errorf("NewBank() has %d layers, want 11", len(b.layers)) + } + // Verify each class has exactly one layer + for _, class := range classify.AllClasses() { + if _, ok := b.layers[class]; !ok { + t.Errorf("NewBank() missing layer for class %q", class) + } + } +} + +func TestRenderWindowOutputLength(t *testing.T) { + b := NewBank(1.0) + snap := classify.WindowSnapshot{ + Counts: make(map[classify.TrafficClass]int64), + TotalPackets: 0, + WindowIndex: 0, + } + frames := b.RenderWindow(snap) + if len(frames) != SamplesPerWindow { + t.Errorf("RenderWindow returned %d frames, want %d (SamplesPerWindow)", len(frames), SamplesPerWindow) + } +} + +func TestRenderWindowSilentWhenNoTraffic(t *testing.T) { + b := NewBank(1.0) + // Empty counts — no class ever seen — all layers should stay at zero amplitude + snap := classify.WindowSnapshot{ + Counts: make(map[classify.TrafficClass]int64), + TotalPackets: 0, + WindowIndex: 0, + } + frames := b.RenderWindow(snap) + for i, frame := range frames { + if frame[0] != 0.0 || frame[1] != 0.0 { + t.Errorf("frame[%d] = [%v, %v], want [0, 0] (silent when no traffic seen)", i, frame[0], frame[1]) + break + } + } +} + +func TestRenderWindowNonZeroWithTraffic(t *testing.T) { + b := NewBank(1.0) + counts := make(map[classify.TrafficClass]int64) + counts[classify.ClassICMP] = 100 + snap := classify.WindowSnapshot{ + Counts: counts, + TotalPackets: 100, + WindowIndex: 0, + } + frames := b.RenderWindow(snap) + // Check that at least some frames are non-zero + hasNonZero := false + for _, frame := range frames { + if frame[0] != 0.0 || frame[1] != 0.0 { + hasNonZero = true + break + } + } + if !hasNonZero { + t.Error("RenderWindow with ICMP count=100 should produce non-zero frames") + } +} + +func TestMixerNoClip(t *testing.T) { + b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude + counts := make(map[classify.TrafficClass]int64) + // All 11 classes at max count — worst-case mixing scenario + for _, class := range classify.AllClasses() { + counts[class] = 1000 + } + snap := classify.WindowSnapshot{ + Counts: counts, + TotalPackets: 11000, + WindowIndex: 0, + } + // Render multiple windows to let EMA converge + for i := 0; i < 10; i++ { + frames := b.RenderWindow(snap) + for _, frame := range frames { + if frame[0] > 1.0 || frame[0] < -1.0 { + t.Errorf("left channel clipped: %v (exceeds [-1, 1])", frame[0]) + return + } + if frame[1] > 1.0 || frame[1] < -1.0 { + t.Errorf("right channel clipped: %v (exceeds [-1, 1])", frame[1]) + return + } + } + } +} + +func TestStereoPan(t *testing.T) { + b := NewBank(0.01) // fast EMA + counts := make(map[classify.TrafficClass]int64) + // ClassDHCP has pan=-0.75 (wide-left in config.go) + counts[classify.ClassDHCP] = 1000 + snap := classify.WindowSnapshot{ + Counts: counts, + TotalPackets: 1000, + WindowIndex: 0, + } + // Render multiple windows to allow EMA to build up amplitude + var frames [][2]float64 + for i := 0; i < 5; i++ { + frames = b.RenderWindow(snap) + } + // Compute RMS for L and R channels + var sumL2, sumR2 float64 + for _, frame := range frames { + sumL2 += frame[0] * frame[0] + sumR2 += frame[1] * frame[1] + } + rmsL := math.Sqrt(sumL2 / float64(len(frames))) + rmsR := math.Sqrt(sumR2 / float64(len(frames))) + if rmsL <= rmsR { + t.Errorf("ClassDHCP (pan=-0.75) should have rmsL > rmsR; got rmsL=%v, rmsR=%v", rmsL, rmsR) + } +} + +func TestMultipleWindowsEMAConvergence(t *testing.T) { + b := NewBank(1.0) + counts := make(map[classify.TrafficClass]int64) + counts[classify.ClassICMP] = 100 + snap := classify.WindowSnapshot{ + Counts: counts, + TotalPackets: 100, + WindowIndex: 0, + } + // Compute RMS for first and last window render + rmsFirst := windowRMS(b.RenderWindow(snap)) + // Render 4 more windows with the same snapshot + var rmsLast float64 + for i := 0; i < 4; i++ { + rmsLast = windowRMS(b.RenderWindow(snap)) + } + if rmsLast <= rmsFirst { + t.Errorf("EMA should converge upward: rmsFirst=%v, rmsLast=%v", rmsFirst, rmsLast) + } +} + +// windowRMS computes the root mean square amplitude across all stereo frames. +func windowRMS(frames [][2]float64) float64 { + var sum float64 + for _, frame := range frames { + sum += frame[0]*frame[0] + frame[1]*frame[1] + } + return math.Sqrt(sum / float64(len(frames)*2)) +}