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
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user