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
This commit is contained in:
2026-03-26 17:37:44 +01:00
parent 41e22788fc
commit 43307c31d5
2 changed files with 19 additions and 17 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error
} }
// Render all windows to stereo frames // 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 var allFrames [][2]float64
for _, snap := range snapshots { for _, snap := range snapshots {
frames := bank.RenderWindow(snap) frames := bank.RenderWindow(snap)
+18 -16
View File
@@ -2,22 +2,25 @@ package synth
import "github.com/netsynth/netsynth/classify" 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. // It consumes WindowSnapshot data and renders stereo PCM frames.
type OscillatorBank struct { type OscillatorBank struct {
layers map[classify.TrafficClass]*Layer layers map[classify.TrafficClass]*Layer
tau float64 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). // 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{ b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, NumLayers), layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau, tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)),
} }
for _, class := range classify.AllClasses() { for class, cfg := range cfgs {
cfg := ClassFreqConfigs[class]
b.layers[class] = NewLayer(cfg, SampleRate, tau) b.layers[class] = NewLayer(cfg, SampleRate, tau)
} }
return b return b
@@ -25,7 +28,7 @@ func NewBank(tau float64) *OscillatorBank {
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow // RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1]. // 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 { func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
// Find max count for normalization // Find max count for normalization
var maxCount int64 var maxCount int64
@@ -36,21 +39,20 @@ func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64
} }
// Update target amplitudes for all layers // Update target amplitudes for all layers
for _, class := range classify.AllClasses() { for class, layer := range b.layers {
count := snap.Counts[class] count := snap.Counts[class]
b.layers[class].UpdateTarget(count, maxCount) layer.UpdateTarget(count, maxCount)
} }
// Render frames // Render frames
frames := make([][2]float64, SamplesPerWindow) frames := make([][2]float64, SamplesPerWindow)
for i := range frames { for i := range frames {
var sumL, sumR float64 var sumL, sumR float64
for _, class := range classify.AllClasses() { for _, layer := range b.layers {
layer := b.layers[class]
sample := layer.AdvanceSample() sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan) gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * GainPerLayer * gainL sumL += sample * b.gainPerLayer * gainL
sumR += sample * GainPerLayer * gainR sumR += sample * b.gainPerLayer * gainR
} }
frames[i] = [2]float64{sumL, sumR} frames[i] = [2]float64{sumL, sumR}
} }