Merge branch 'worktree-agent-a9867cbf'
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
+18
-16
@@ -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}
|
||||
}
|
||||
|
||||
+47
-9
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user