- Add TestGroupFieldPopulated test to verify all ClassFreqConfigs have non-empty Group (GRP-01) - Change TestHarmonicsNonEmpty threshold from < 2 to < 1 (accepts single-harmonic sine entries) - Fix oscillator Advance() to use math.Abs(h.Amplitude) for normalization weight accumulation: triangle wave uses alternating-sign amplitudes; signed sum underestimates totalWeight causing output to exceed [-1,1] bounds when using WaveformPresetHarmonics (Rule 1 bug fix) - Update TestStereoPan to use ClassSSH (pan=-0.7) instead of ClassDHCP (pan changed to 0.1) - Update TestNewBankCustomConfigNoClip: passes after oscillator normalization fix - Fix TestLoadPartialOverrideFrequency: derive expected WaveformType from defaults (not hardcoded 0) - Fix TestAutoFreqSkipsBuiltins: derive expected HTTPS BaseHz from defaults (150.0 in Phase 9)
199 lines
5.7 KiB
Go
199 lines
5.7 KiB
Go
package synth
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
|
|
"github.com/netsynth/netsynth/classify"
|
|
)
|
|
|
|
func TestNewBankHas14Layers(t *testing.T) {
|
|
b := NewBank(1.0, ClassFreqConfigs)
|
|
if len(b.layers) != 14 {
|
|
t.Errorf("NewBank() has %d layers, want 14", 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, ClassFreqConfigs)
|
|
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, ClassFreqConfigs)
|
|
// 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, ClassFreqConfigs)
|
|
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, 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 ClassFreqConfigs {
|
|
counts[class] = 1000
|
|
}
|
|
snap := classify.WindowSnapshot{
|
|
Counts: counts,
|
|
TotalPackets: int64(len(ClassFreqConfigs)) * 1000,
|
|
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, ClassFreqConfigs) // fast EMA
|
|
counts := make(map[classify.TrafficClass]int64)
|
|
// ClassSSH has pan=-0.7 (wide-left in Phase 9 config.go)
|
|
counts[classify.ClassSSH] = 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("ClassSSH (pan=-0.7) should have rmsL > rmsR; got rmsL=%v, rmsR=%v", rmsL, rmsR)
|
|
}
|
|
}
|
|
|
|
func TestMultipleWindowsEMAConvergence(t *testing.T) {
|
|
b := NewBank(1.0, ClassFreqConfigs)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
for _, frame := range frames {
|
|
sum += frame[0]*frame[0] + frame[1]*frame[1]
|
|
}
|
|
return math.Sqrt(sum / float64(len(frames)*2))
|
|
}
|