Files
yoloyolo/.planning/phases/02-audio-synthesis-engine/02-02-PLAN.md
T

325 lines
12 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- From synth/config.go (created in Plan 01) -->
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
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Stereo mixer utilities</name>
<files>synth/mixer.go, synth/mixer_test.go</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
**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).
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: OscillatorBank — multi-layer rendering from WindowSnapshot</name>
<files>synth/bank.go, synth/bank_test.go</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
**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).
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md`
</output>