--- phase: 02-audio-synthesis-engine plan: 02 type: execute wave: 2 depends_on: ["02-01"] files_modified: - synth/bank.go - synth/mixer.go - synth/bank_test.go - synth/mixer_test.go autonomous: true requirements: [SYNTH-03] must_haves: truths: - "OscillatorBank updates all 11 layers from a WindowSnapshot and renders stereo PCM frames" - "Mixer sums 11 layers with fixed 1/11 gain per layer — peak sum never exceeds 1.0" - "Stereo panning uses constant-power pan law producing distinct L/R values for non-center sources" - "Full render pipeline: WindowSnapshot -> OscillatorBank.RenderWindow -> [][2]float64 stereo frames" artifacts: - path: "synth/bank.go" provides: "OscillatorBank with NewBank, RenderWindow methods" contains: "func (b *OscillatorBank) RenderWindow" - path: "synth/mixer.go" provides: "panGains constant-power function and float64-to-int16 conversion" contains: "func PanGains" key_links: - from: "synth/bank.go" to: "synth/layer.go" via: "Bank holds map[TrafficClass]*Layer" pattern: "map\\[classify\\.TrafficClass\\]\\*Layer" - from: "synth/bank.go" to: "synth/config.go" via: "Reads ClassFreqConfigs to initialize layers" pattern: "ClassFreqConfigs" - from: "synth/mixer.go" to: "synth/bank.go" via: "StereoFramesToInt16Bytes converts bank output to encoder-ready bytes" pattern: "StereoFramesToInt16Bytes" --- Build the oscillator bank (11 layers driven by WindowSnapshot) and stereo mixer with int16 PCM conversion. This completes SYNTH-03 (mixing without distortion) and prepares the PCM output format needed by the MP3 encoder in Plan 03. Purpose: The bank is the core synthesis engine that transforms traffic snapshots into stereo audio frames. The mixer ensures no clipping via fixed gain and provides constant-power stereo panning. Output: `synth/bank.go`, `synth/mixer.go` with tests. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/02-audio-synthesis-engine/02-CONTEXT.md @.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md @.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md @classify/types.go From synth/config.go: ```go const ( SampleRate = 44100 WindowMs = 500 SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050 NumLayers = 11 GainPerLayer = 1.0 / float64(NumLayers) WhisperFloor = 0.03 ) type HarmonicDef struct { Ratio int Amplitude float64 } type FreqConfig struct { BaseHz float64 Harmonics []HarmonicDef Pan float64 } var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } ``` From synth/oscillator.go: ```go func NewOscillator(freq float64, sampleRate int) *Oscillator func (o *Oscillator) Advance(harmonics []HarmonicDef) float64 ``` From synth/layer.go: ```go func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer func (l *Layer) UpdateTarget(count int64, maxCount int64) func (l *Layer) AdvanceSample() float64 func (l *Layer) CurrentAmp() float64 ``` From classify/types.go: ```go func AllClasses() []TrafficClass type WindowSnapshot struct { Counts map[TrafficClass]int64 TotalPackets int64 WindowIndex int } ``` Task 1: Stereo mixer utilities synth/mixer.go, synth/mixer_test.go - synth/config.go (constants: GainPerLayer, SamplesPerWindow, SampleRate) - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 3: constant-power panning, Pattern 4: int16 conversion) - .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10, D-11, D-12, D-13) - TestPanGainsCenter: PanGains(0.0) returns gainL ~= 0.707, gainR ~= 0.707 (cos(pi/4), sin(pi/4)) - TestPanGainsFullLeft: PanGains(-1.0) returns gainL ~= 1.0, gainR ~= 0.0 - TestPanGainsFullRight: PanGains(1.0) returns gainL ~= 0.0, gainR ~= 1.0 - TestPanGainsPowerPreserved: For any pan value, gainL^2 + gainR^2 ~= 1.0 (constant power) - TestStereoFramesToInt16Bytes: [][2]float64{{1.0, -1.0}} produces 4 bytes: int16(32767) LE then int16(-32767) LE - TestStereoFramesToInt16BytesZero: [][2]float64{{0.0, 0.0}} produces 4 zero bytes - TestClampPreventsOverflow: float64 value > 1.0 is clamped to 1.0 before int16 conversion (no wrap-around) **synth/mixer.go** — Constant-power pan law (per D-11) and PCM conversion utilities: ```go package synth import ( "encoding/binary" "math" ) // PanGains returns left and right channel gains for a pan position p in [-1, 1]. // Uses constant-power (equal-power) pan law: cos/sin mapping. // Per D-11: bass frequencies center, mid spread L/R, higher frequencies wider. func PanGains(p float64) (gainL, gainR float64) { angle := (p + 1.0) / 2.0 * math.Pi / 2.0 return math.Cos(angle), math.Sin(angle) } // StereoFramesToInt16Bytes converts [][2]float64 stereo frames to interleaved // little-endian int16 bytes suitable for go-lame's Write method. // Clamps values to [-1.0, 1.0] before conversion. // Output format: [L0_lo, L0_hi, R0_lo, R0_hi, L1_lo, L1_hi, R1_lo, R1_hi, ...] func StereoFramesToInt16Bytes(frames [][2]float64) []byte { buf := make([]byte, len(frames)*4) // 2 channels * 2 bytes per sample for i, frame := range frames { l := clamp(frame[0]) r := clamp(frame[1]) binary.LittleEndian.PutUint16(buf[i*4:], uint16(int16(l*32767))) binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(int16(r*32767))) } return buf } func clamp(v float64) float64 { if v > 1.0 { return 1.0 } if v < -1.0 { return -1.0 } return v } ``` Write tests FIRST (RED), then implementation (GREEN). cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1 - synth/mixer.go contains `func PanGains(p float64) (gainL, gainR float64)` - synth/mixer.go contains `func StereoFramesToInt16Bytes(frames [][2]float64) []byte` - synth/mixer.go contains `math.Cos(angle), math.Sin(angle)` (constant-power, not linear) - synth/mixer.go contains `func clamp(v float64) float64` - synth/mixer_test.go contains `TestPanGainsCenter` - synth/mixer_test.go contains `TestStereoFramesToInt16Bytes` - `go test ./synth/... -run "TestPan|TestStereo|TestClamp"` exits 0 Constant-power pan law produces correct L/R gains for all positions. Stereo frames convert to interleaved int16 LE bytes with clamping. All tests pass. Task 2: OscillatorBank — multi-layer rendering from WindowSnapshot synth/bank.go, synth/bank_test.go - synth/config.go (ClassFreqConfigs, GainPerLayer, SamplesPerWindow, NumLayers) - synth/layer.go (NewLayer, UpdateTarget, AdvanceSample) - synth/mixer.go (PanGains) - classify/types.go (AllClasses, WindowSnapshot) - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 6: per-window render loop) - .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10: fixed 1/11 gain) - TestNewBankHas11Layers: NewBank() creates exactly 11 layers, one per TrafficClass - TestRenderWindowOutputLength: RenderWindow with any snapshot returns exactly SamplesPerWindow (22050) stereo frames - TestRenderWindowSilentWhenNoTraffic: RenderWindow with empty counts (all zeros, no class ever seen) produces all-zero frames - TestRenderWindowNonZeroWithTraffic: RenderWindow with ClassICMP count=100 produces non-zero L and R samples - TestMixerNoClip: RenderWindow with ALL 11 classes at max count — no frame has |L| > 1.0 or |R| > 1.0 (D-10 guarantees this) - TestStereoPan: RenderWindow with only ClassDHCP (pan=-0.75) — left channel RMS > right channel RMS (wide-left) - TestMultipleWindowsEMAConvergence: RenderWindow called 5 times with same snapshot — later windows have higher amplitude than first (EMA ramp-up) **synth/bank.go** — The core synthesis engine. Per D-10, each layer gets GainPerLayer (1/11) of headroom: ```go 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]. 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 } ``` Key design points: - `GainPerLayer` (1/11) is applied during mixing, not in the layer (per D-10). This ensures 11 max-amplitude layers sum to exactly 1.0. - `classify.AllClasses()` is used for iteration order consistency. - The `AdvanceSample()` call both advances the oscillator phase AND applies EMA smoothing (from layer.go). Write tests FIRST (RED), then implementation (GREEN). cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1 - synth/bank.go contains `func NewBank(tau float64) *OscillatorBank` - synth/bank.go contains `func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64` - synth/bank.go contains `GainPerLayer` (the 1/11 gain applied per layer) - synth/bank.go imports `github.com/netsynth/netsynth/classify` - synth/bank_test.go contains `TestMixerNoClip` - synth/bank_test.go contains `TestStereoPan` - synth/bank_test.go contains `TestRenderWindowOutputLength` - `go test ./synth/...` exits 0 (full synth package passes) OscillatorBank renders 22050 stereo frames per window. 11 layers mixed with 1/11 gain never clip. Panned sources produce asymmetric L/R output. EMA converges over multiple windows. All synth tests pass. - `go test ./synth/... -v` passes all tests (config, oscillator, layer, mixer, bank) - No test contains a TODO or Skip marker - `grep -r "GainPerLayer" synth/bank.go` confirms fixed-gain mixing - `grep "PanGains" synth/mixer.go synth/bank.go` confirms pan is used in both files - OscillatorBank creates 11 layers from ClassFreqConfigs and renders stereo PCM from WindowSnapshot - Constant-power panning produces correct L/R gains per D-12 positions - Fixed 1/11 gain per layer prevents clipping by construction (D-10) - Int16 byte conversion with clamping ready for go-lame encoder - All synth/ tests pass After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md`