From 43307c31d5b94be7c49354430f96b286f096dec8 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 17:37:44 +0100 Subject: [PATCH 1/2] feat(05-02): decouple NewBank from global config and fix dynamic GainPerLayer - NewBank now accepts (tau float64, cfgs map[classify.TrafficClass]FreqConfig) - gainPerLayer field added to OscillatorBank, computed as 1.0/float64(len(cfgs)) - RenderWindow UpdateTarget loop iterates b.layers (not classify.AllClasses()) - RenderWindow render loop uses b.gainPerLayer (not GainPerLayer constant) - encode/mp3.go updated to pass synth.ClassFreqConfigs as default config map --- encode/mp3.go | 2 +- synth/bank.go | 34 ++++++++++++++++++---------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/encode/mp3.go b/encode/mp3.go index 534c45e..3279dca 100644 --- a/encode/mp3.go +++ b/encode/mp3.go @@ -54,7 +54,7 @@ func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error } // Render all windows to stereo frames - bank := synth.NewBank(1.0) // tau=1.0s per D-07 + bank := synth.NewBank(1.0, synth.ClassFreqConfigs) // tau=1.0s per D-07 var allFrames [][2]float64 for _, snap := range snapshots { frames := bank.RenderWindow(snap) diff --git a/synth/bank.go b/synth/bank.go index 4bf80e0..592f628 100644 --- a/synth/bank.go +++ b/synth/bank.go @@ -2,22 +2,25 @@ package synth import "github.com/netsynth/netsynth/classify" -// OscillatorBank holds 11 synthesis layers, one per TrafficClass. +// 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 + layers map[classify.TrafficClass]*Layer + tau float64 + gainPerLayer float64 } -// NewBank creates an OscillatorBank with one Layer per TrafficClass. +// 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). -func NewBank(tau float64) *OscillatorBank { +// gainPerLayer is computed dynamically as 1/len(cfgs) so that all layers at max +// amplitude sum to exactly 1.0 (no clipping), regardless of how many classes are active. +func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank { b := &OscillatorBank{ - layers: make(map[classify.TrafficClass]*Layer, NumLayers), - tau: tau, + layers: make(map[classify.TrafficClass]*Layer, len(cfgs)), + tau: tau, + gainPerLayer: 1.0 / float64(len(cfgs)), } - for _, class := range classify.AllClasses() { - cfg := ClassFreqConfigs[class] + for class, cfg := range cfgs { b.layers[class] = NewLayer(cfg, SampleRate, tau) } return b @@ -25,7 +28,7 @@ func NewBank(tau float64) *OscillatorBank { // 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). +// Each layer gets 1/N of the total gain where N is the number of layers. func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 { // Find max count for normalization var maxCount int64 @@ -36,21 +39,20 @@ func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 } // Update target amplitudes for all layers - for _, class := range classify.AllClasses() { + for class, layer := range b.layers { count := snap.Counts[class] - b.layers[class].UpdateTarget(count, maxCount) + layer.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] + for _, layer := range b.layers { sample := layer.AdvanceSample() gainL, gainR := PanGains(layer.Config.Pan) - sumL += sample * GainPerLayer * gainL - sumR += sample * GainPerLayer * gainR + sumL += sample * b.gainPerLayer * gainL + sumR += sample * b.gainPerLayer * gainR } frames[i] = [2]float64{sumL, sumR} } From b2b5ab679b6abea823674a512f56dbd949fe491e Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 17:38:37 +0100 Subject: [PATCH 2/2] test(05-02): update tests for new NewBank signature and dynamic gain - All NewBank calls updated to two-argument form passing ClassFreqConfigs - TestMixerNoClip iterates ClassFreqConfigs keys instead of classify.AllClasses() - TestNewBankDynamicGain verifies gainPerLayer=1/N for 3-class custom config - TestNewBankCustomConfigNoClip verifies no-clip guarantee with 2-class config - TestNumLayersMatchesAllClasses now asserts len(ClassFreqConfigs) == len(AllClasses()) --- synth/bank_test.go | 56 +++++++++++++++++++++++++++++++++++++------- synth/config_test.go | 5 ++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/synth/bank_test.go b/synth/bank_test.go index 4056bda..37dd5e2 100644 --- a/synth/bank_test.go +++ b/synth/bank_test.go @@ -8,7 +8,7 @@ import ( ) func TestNewBankHas14Layers(t *testing.T) { - b := NewBank(1.0) + b := NewBank(1.0, ClassFreqConfigs) if len(b.layers) != 14 { t.Errorf("NewBank() has %d layers, want 14", len(b.layers)) } @@ -21,7 +21,7 @@ func TestNewBankHas14Layers(t *testing.T) { } func TestRenderWindowOutputLength(t *testing.T) { - b := NewBank(1.0) + b := NewBank(1.0, ClassFreqConfigs) snap := classify.WindowSnapshot{ Counts: make(map[classify.TrafficClass]int64), TotalPackets: 0, @@ -34,7 +34,7 @@ func TestRenderWindowOutputLength(t *testing.T) { } func TestRenderWindowSilentWhenNoTraffic(t *testing.T) { - b := NewBank(1.0) + b := NewBank(1.0, ClassFreqConfigs) // Empty counts — no class ever seen — all layers should stay at zero amplitude snap := classify.WindowSnapshot{ Counts: make(map[classify.TrafficClass]int64), @@ -51,7 +51,7 @@ func TestRenderWindowSilentWhenNoTraffic(t *testing.T) { } func TestRenderWindowNonZeroWithTraffic(t *testing.T) { - b := NewBank(1.0) + b := NewBank(1.0, ClassFreqConfigs) counts := make(map[classify.TrafficClass]int64) counts[classify.ClassICMP] = 100 snap := classify.WindowSnapshot{ @@ -74,15 +74,15 @@ func TestRenderWindowNonZeroWithTraffic(t *testing.T) { } func TestMixerNoClip(t *testing.T) { - b := NewBank(0.01) // fast EMA to quickly ramp up to near-max amplitude + b := NewBank(0.01, ClassFreqConfigs) // fast EMA to quickly ramp up to near-max amplitude counts := make(map[classify.TrafficClass]int64) // All 14 classes at max count — worst-case mixing scenario - for _, class := range classify.AllClasses() { + for class := range ClassFreqConfigs { counts[class] = 1000 } snap := classify.WindowSnapshot{ Counts: counts, - TotalPackets: 14000, + TotalPackets: int64(len(ClassFreqConfigs)) * 1000, WindowIndex: 0, } // Render multiple windows to let EMA converge @@ -102,7 +102,7 @@ func TestMixerNoClip(t *testing.T) { } func TestStereoPan(t *testing.T) { - b := NewBank(0.01) // fast EMA + b := NewBank(0.01, ClassFreqConfigs) // fast EMA counts := make(map[classify.TrafficClass]int64) // ClassDHCP has pan=-0.75 (wide-left in config.go) counts[classify.ClassDHCP] = 1000 @@ -130,7 +130,7 @@ func TestStereoPan(t *testing.T) { } func TestMultipleWindowsEMAConvergence(t *testing.T) { - b := NewBank(1.0) + b := NewBank(1.0, ClassFreqConfigs) counts := make(map[classify.TrafficClass]int64) counts[classify.ClassICMP] = 100 snap := classify.WindowSnapshot{ @@ -150,6 +150,44 @@ func TestMultipleWindowsEMAConvergence(t *testing.T) { } } +func TestNewBankDynamicGain(t *testing.T) { + // Create a config map with only 3 classes + cfgs := map[classify.TrafficClass]FreqConfig{ + classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP], + classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS], + classify.ClassHTTPS: ClassFreqConfigs[classify.ClassHTTPS], + } + b := NewBank(0.01, cfgs) + if len(b.layers) != 3 { + t.Errorf("NewBank with 3 configs has %d layers, want 3", len(b.layers)) + } + // Verify gainPerLayer is 1/3 + expected := 1.0 / 3.0 + if b.gainPerLayer != expected { + t.Errorf("gainPerLayer = %v, want %v", b.gainPerLayer, expected) + } +} + +func TestNewBankCustomConfigNoClip(t *testing.T) { + cfgs := map[classify.TrafficClass]FreqConfig{ + classify.ClassICMP: ClassFreqConfigs[classify.ClassICMP], + classify.ClassDNS: ClassFreqConfigs[classify.ClassDNS], + } + b := NewBank(0.01, cfgs) + counts := map[classify.TrafficClass]int64{ + classify.ClassICMP: 1000, + classify.ClassDNS: 1000, + } + snap := classify.WindowSnapshot{Counts: counts, TotalPackets: 2000, WindowIndex: 0} + for i := 0; i < 10; i++ { + for _, frame := range b.RenderWindow(snap) { + if frame[0] > 1.0 || frame[0] < -1.0 || frame[1] > 1.0 || frame[1] < -1.0 { + t.Fatalf("clipped with 2-class config: L=%v R=%v", frame[0], frame[1]) + } + } + } +} + // windowRMS computes the root mean square amplitude across all stereo frames. func windowRMS(frames [][2]float64) float64 { var sum float64 diff --git a/synth/config_test.go b/synth/config_test.go index f1e822c..e969a7e 100644 --- a/synth/config_test.go +++ b/synth/config_test.go @@ -59,7 +59,8 @@ func TestClassFreqConfigsComplete(t *testing.T) { } func TestNumLayersMatchesAllClasses(t *testing.T) { - if synth.NumLayers != len(classify.AllClasses()) { - t.Errorf("NumLayers=%d but AllClasses() has %d entries", synth.NumLayers, len(classify.AllClasses())) + if len(synth.ClassFreqConfigs) != len(classify.AllClasses()) { + t.Errorf("ClassFreqConfigs has %d entries but AllClasses() has %d entries", + len(synth.ClassFreqConfigs), len(classify.AllClasses())) } }