docs(05): create phase plan

This commit is contained in:
2026-03-26 17:26:46 +01:00
parent 9fe5d37f1e
commit 716ffa82ee
3 changed files with 572 additions and 2 deletions
+6 -2
View File
@@ -37,7 +37,11 @@ Full details: `.planning/milestones/v1.0-ROADMAP.md`
1. User can set a traffic class to square, sawtooth, or triangle waveform and hear a tonally distinct sound with no audible aliasing or buzzing artifacts
2. Sine waveform continues to produce the same output as v1.0 — no regression
3. The synthesis bank builds layers from a passed-in config map rather than a hardcoded class list
**Plans**: TBD
**Plans:** 2 plans
Plans:
- [ ] 05-01-PLAN.md — Waveform types: WaveformType enum, WaveformPresetHarmonics, NewLayer resolution
- [ ] 05-02-PLAN.md — Bank decoupling: NewBank injected config map, dynamic GainPerLayer, test updates
### Phase 6: Config Package and Sound Overrides
**Goal**: Users can create a TOML config file to override frequency and waveform per traffic class, with auto-discovery, partial override semantics, and clear validation errors
@@ -70,6 +74,6 @@ Full details: `.planning/milestones/v1.0-ROADMAP.md`
| 2. Audio Synthesis Engine | v1.0 | 3/3 | Complete | 2026-03-26 |
| 3. Pipeline Integration and MVP | v1.0 | 2/2 | Complete | 2026-03-26 |
| 4. Power User Features | v1.0 | 2/2 | Complete | 2026-03-26 |
| 5. Waveform Types and Bank Decoupling | v1.1 | 0/? | Not started | - |
| 5. Waveform Types and Bank Decoupling | v1.1 | 0/2 | Not started | - |
| 6. Config Package and Sound Overrides | v1.1 | 0/? | Not started | - |
| 7. Custom Rules and Print-Config | v1.1 | 0/? | Not started | - |
@@ -0,0 +1,216 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- synth/config.go
- synth/layer.go
- synth/waveform_test.go
autonomous: true
requirements:
- WAVE-01
- WAVE-02
must_haves:
truths:
- "WaveformType enum exists with five values: WaveformCustom (0), WaveformSine, WaveformSquare, WaveformSawtooth, WaveformTriangle"
- "WaveformPresetHarmonics returns correct bandlimited harmonic series for each waveform type"
- "All generated partials are below Nyquist frequency (22050 Hz)"
- "WaveformCustom returns nil, preserving existing hand-tuned harmonics"
- "NewLayer resolves waveform presets at construction time, not at render time"
- "Existing tests still pass — no regression in v1.0 behavior"
artifacts:
- path: "synth/config.go"
provides: "WaveformType enum and WaveformPresetHarmonics function"
contains: "WaveformType"
exports: ["WaveformType", "WaveformCustom", "WaveformSine", "WaveformSquare", "WaveformSawtooth", "WaveformTriangle", "WaveformPresetHarmonics"]
- path: "synth/layer.go"
provides: "Waveform resolution in NewLayer"
contains: "WaveformPresetHarmonics"
- path: "synth/waveform_test.go"
provides: "Tests for waveform preset generation and bandlimiting"
key_links:
- from: "synth/layer.go"
to: "synth/config.go"
via: "NewLayer calls WaveformPresetHarmonics when cfg.WaveformType != WaveformCustom"
pattern: "WaveformPresetHarmonics\\(cfg\\.WaveformType"
---
<objective>
Add four waveform types (sine, square, sawtooth, triangle) to the synthesis engine using bandlimited additive synthesis.
Purpose: Enables per-traffic-class waveform selection (WAVE-01) with aliasing-free generation (WAVE-02). This is the foundation that Phase 6 config loading will expose to users.
Output: WaveformType enum, WaveformPresetHarmonics() function, NewLayer waveform resolution, and comprehensive 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/STATE.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
@synth/config.go
@synth/layer.go
@synth/oscillator.go
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From synth/config.go:
```go
type HarmonicDef struct {
Ratio int
Amplitude float64
}
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
}
const SampleRate = 44100
```
From synth/oscillator.go:
```go
func (o *Oscillator) Advance(harmonics []HarmonicDef) float64
```
From synth/layer.go:
```go
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
func (l *Layer) AdvanceSample() float64 // calls l.Osc.Advance(l.Config.Harmonics)
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add WaveformType enum and WaveformPresetHarmonics function</name>
<files>synth/config.go, synth/waveform_test.go</files>
<read_first>synth/config.go, synth/oscillator.go, synth/layer.go</read_first>
<behavior>
- TestWaveformPresetHarmonics_Sine: WaveformPresetHarmonics(WaveformSine, 440.0, 44100) returns exactly []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
- TestWaveformPresetHarmonics_Square: WaveformPresetHarmonics(WaveformSquare, 440.0, 44100) returns odd harmonics (1,3,5,...) with amplitude 1/k, all below Nyquist
- TestWaveformPresetHarmonics_Sawtooth: WaveformPresetHarmonics(WaveformSawtooth, 440.0, 44100) returns all harmonics (1,2,3,...) with amplitude 1/k, all below Nyquist
- TestWaveformPresetHarmonics_Triangle: WaveformPresetHarmonics(WaveformTriangle, 440.0, 44100) returns odd harmonics with alternating sign and 1/k^2 amplitude, all below Nyquist
- TestWaveformPresetHarmonics_Custom: WaveformPresetHarmonics(WaveformCustom, 440.0, 44100) returns nil
- TestBandlimitedHarmonicsNoAliasing: For each non-custom waveform type, at every ClassFreqConfigs base frequency, no harmonic's Ratio*baseHz exceeds 22050
- TestWaveformPresetHarmonics_SquareOddOnly: All returned ratios for square are odd numbers
- TestWaveformPresetHarmonics_TriangleOddOnly: All returned ratios for triangle are odd numbers
- TestWaveformPresetHarmonics_SawtoothConsecutive: Returned ratios for sawtooth are consecutive integers starting at 1
</behavior>
<action>
Per D-01 and D-02, add to synth/config.go:
1. Define WaveformType as `type WaveformType int` with five constants:
```go
const (
WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
```
2. Add `WaveformType WaveformType` field to the `FreqConfig` struct (after Pan). Zero value is WaveformCustom, so all existing ClassFreqConfigs entries automatically use their hand-tuned harmonics (per D-03).
3. Add function `WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef`:
- WaveformCustom: return nil
- WaveformSine: return `[]HarmonicDef{{Ratio: 1, Amplitude: 1.0}}`
- WaveformSquare: loop `k := 1; float64(k)*baseHz < nyquist; k += 2` — append `HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}`
- WaveformSawtooth: loop `k := 1; float64(k)*baseHz < nyquist; k++` — append `HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)}`
- WaveformTriangle: loop `k := 1; float64(k)*baseHz < nyquist; k += 2` with alternating sign — append `HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)}`, then `sign = -sign` (start `sign := 1.0`)
- Nyquist is `float64(sampleRate) / 2.0`
4. Do NOT modify ClassFreqConfigs entries — they retain their hand-tuned harmonics with the default WaveformCustom zero value (per D-03).
5. Create synth/waveform_test.go (package synth_test) with all tests from the behavior block. Use `synth.WaveformPresetHarmonics(...)` calls. The bandlimit test should iterate all ClassFreqConfigs entries, call WaveformPresetHarmonics for each of {WaveformSine, WaveformSquare, WaveformSawtooth, WaveformTriangle} with that entry's BaseHz, and assert `float64(h.Ratio) * baseHz < 22050.0` for every returned HarmonicDef.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestWaveformPreset|TestBandlimited" -v</automated>
</verify>
<acceptance_criteria>
- synth/config.go contains `type WaveformType int`
- synth/config.go contains `WaveformCustom WaveformType = iota`
- synth/config.go contains `WaveformSine`, `WaveformSquare`, `WaveformSawtooth`, `WaveformTriangle`
- synth/config.go contains `func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef`
- FreqConfig struct contains `WaveformType WaveformType`
- synth/waveform_test.go exists and contains `TestWaveformPresetHarmonics` and `TestBandlimitedHarmonicsNoAliasing`
- `go test ./synth/... -run "TestWaveformPreset|TestBandlimited"` exits 0
- `go test ./synth/...` exits 0 (no regression in existing tests)
</acceptance_criteria>
<done>WaveformType enum exported with 5 values, WaveformPresetHarmonics generates correct bandlimited series for all 4 waveform types, returns nil for WaveformCustom, all tests pass including existing suite</done>
</task>
<task type="auto">
<name>Task 2: Wire waveform resolution into NewLayer</name>
<files>synth/layer.go, synth/waveform_test.go</files>
<read_first>synth/layer.go, synth/config.go, synth/waveform_test.go</read_first>
<action>
Per D-02 and research Pattern 2, modify `NewLayer` in synth/layer.go to resolve waveform presets at construction time:
1. In `NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer`, add waveform resolution BEFORE creating the Layer. Insert at the top of the function:
```go
if cfg.WaveformType != WaveformCustom {
cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
}
```
This overwrites cfg.Harmonics (the local copy, not the original) with the bandlimited preset. The rest of NewLayer is unchanged — it stores cfg in `Layer.Config`, so `AdvanceSample` calls `l.Osc.Advance(l.Config.Harmonics)` with the resolved harmonics.
2. Add two tests to synth/waveform_test.go:
`TestNewLayerResolvesWaveformPreset`: Create a `synth.FreqConfig{BaseHz: 440.0, WaveformType: synth.WaveformSquare}` with empty Harmonics. Call `synth.NewLayer(cfg, synth.SampleRate, 1.0)`. Assert the returned layer's `Config.Harmonics` has length > 1 (preset was resolved). Verify the first harmonic has Ratio=1.
`TestNewLayerPreservesCustomHarmonics`: Create a `synth.FreqConfig{BaseHz: 440.0, Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}, {Ratio: 2, Amplitude: 0.4}}}` with WaveformType left at zero (WaveformCustom). Call `synth.NewLayer(cfg, synth.SampleRate, 1.0)`. Assert harmonics length is exactly 2 and second harmonic Amplitude is 0.4.
`TestSineRegressionVsCustomHarmonics`: Create two layers — one with `WaveformType: synth.WaveformSine` and empty Harmonics, one with `WaveformType: synth.WaveformCustom` and `Harmonics: []synth.HarmonicDef{{Ratio: 1, Amplitude: 1.0}}`. Advance both 100 samples (calling layer.AdvanceSample on each). Assert samples are identical (both are pure sine at same frequency). Use a target amplitude of 1.0 by calling UpdateTarget(1, 1) first.
Note: The Layer struct fields Config, Osc are exported (capital first letter), so external tests (package synth_test) can access them. However AdvanceSample needs the layer to have a non-zero amplitude — call `layer.UpdateTarget(1, 1)` before advancing to set target to whisper+rate level, then advance enough samples for EMA to converge, OR use a very small tau like 0.001 for fast convergence in tests.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- synth/layer.go NewLayer function contains `if cfg.WaveformType != WaveformCustom`
- synth/layer.go NewLayer function contains `WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)`
- synth/waveform_test.go contains `TestNewLayerResolvesWaveformPreset`
- synth/waveform_test.go contains `TestNewLayerPreservesCustomHarmonics`
- synth/waveform_test.go contains `TestSineRegressionVsCustomHarmonics`
- `go test ./synth/...` exits 0 (all existing tests still pass)
</acceptance_criteria>
<done>NewLayer resolves waveform presets at construction time. Custom harmonics are preserved when WaveformType is zero. Sine preset produces identical output to single-harmonic custom config. All tests pass.</done>
</task>
</tasks>
<verification>
- `go test ./synth/... -v` — all tests pass, including new waveform tests and all existing tests
- `go test ./encode/...` — encode package still compiles and passes (no changes to it in this plan)
- `go vet ./synth/...` — no warnings
</verification>
<success_criteria>
- WaveformType enum with 5 values is exported from synth package
- WaveformPresetHarmonics produces correct harmonic series for all 4 waveform types
- All generated harmonics are below Nyquist (no aliasing)
- WaveformCustom preserves existing hand-tuned harmonics
- NewLayer resolves presets at construction time (not render time)
- Sine waveform preset produces identical output to v1.0 single-harmonic custom
- All existing synth and encode tests pass without modification
</success_criteria>
<output>
After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-01-SUMMARY.md`
</output>
@@ -0,0 +1,350 @@
---
phase: 05-waveform-types-and-bank-decoupling
plan: 02
type: execute
wave: 2
depends_on:
- "05-01"
files_modified:
- synth/bank.go
- synth/bank_test.go
- synth/config_test.go
- encode/mp3.go
autonomous: true
requirements:
- WAVE-01
- WAVE-02
must_haves:
truths:
- "NewBank accepts a config map parameter instead of reading the ClassFreqConfigs global"
- "GainPerLayer is computed dynamically as 1.0/len(configs) inside NewBank"
- "RenderWindow iterates b.layers instead of classify.AllClasses() in both loops"
- "encode.RunSynthesis passes synth.ClassFreqConfigs as the default config map"
- "All 14 built-in classes still produce the same audio output as v1.0"
- "No-clip guarantee holds with dynamic gain scaling"
artifacts:
- path: "synth/bank.go"
provides: "Decoupled OscillatorBank with injected config map"
contains: "gainPerLayer"
exports: ["NewBank", "OscillatorBank", "RenderWindow"]
- path: "encode/mp3.go"
provides: "Updated NewBank call site"
contains: "synth.ClassFreqConfigs"
- path: "synth/bank_test.go"
provides: "Updated tests for new NewBank signature"
- path: "synth/config_test.go"
provides: "Updated TestNumLayersMatchesAllClasses"
key_links:
- from: "encode/mp3.go"
to: "synth/bank.go"
via: "synth.NewBank(1.0, synth.ClassFreqConfigs)"
pattern: "NewBank\\(1\\.0,\\s*synth\\.ClassFreqConfigs\\)"
- from: "synth/bank.go"
to: "synth/layer.go"
via: "NewLayer(cfg, SampleRate, tau) for each config map entry"
pattern: "NewLayer\\(cfg,\\s*SampleRate"
- from: "synth/bank.go"
to: "synth/config.go"
via: "gainPerLayer computed from len(cfgs)"
pattern: "1\\.0\\s*/\\s*float64\\(len\\("
---
<objective>
Decouple OscillatorBank from the global ClassFreqConfigs variable and fix GainPerLayer to be dynamic.
Purpose: Creates the injection seam for Phase 6 config loading (D-05) and fixes gain scaling for variable class counts (D-04). After this plan, NewBank accepts any config map — not just the hardcoded 14 built-in classes.
Output: Updated bank.go with new NewBank signature, updated encode/mp3.go call site, updated 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/STATE.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
@.planning/phases/05-waveform-types-and-bank-decoupling/05-01-SUMMARY.md
@synth/bank.go
@synth/bank_test.go
@synth/config_test.go
@encode/mp3.go
<interfaces>
<!-- Key types and contracts from Plan 01 output -->
From synth/config.go (after Plan 01):
```go
type WaveformType int
const (
WaveformCustom WaveformType = iota
WaveformSine
WaveformSquare
WaveformSawtooth
WaveformTriangle
)
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries, all WaveformCustom
```
From synth/layer.go (after Plan 01):
```go
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
// Now resolves WaveformPresetHarmonics at construction if cfg.WaveformType != WaveformCustom
```
From classify package:
```go
type TrafficClass string
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
func AllClasses() []TrafficClass
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Decouple NewBank and fix GainPerLayer</name>
<files>synth/bank.go, encode/mp3.go</files>
<read_first>synth/bank.go, synth/config.go, encode/mp3.go, synth/layer.go</read_first>
<action>
Per D-04 and D-05, refactor bank.go and update the single caller in encode/mp3.go:
1. In synth/bank.go, add `gainPerLayer float64` field to `OscillatorBank` struct:
```go
type OscillatorBank struct {
layers map[classify.TrafficClass]*Layer
tau float64
gainPerLayer float64
}
```
2. Change `NewBank` signature from `NewBank(tau float64)` to `NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig)`:
```go
func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank {
b := &OscillatorBank{
layers: make(map[classify.TrafficClass]*Layer, len(cfgs)),
tau: tau,
gainPerLayer: 1.0 / float64(len(cfgs)),
}
for class, cfg := range cfgs {
b.layers[class] = NewLayer(cfg, SampleRate, tau)
}
return b
}
```
Key changes: iterate `cfgs` (not `classify.AllClasses()`), compute `gainPerLayer` dynamically from `len(cfgs)` (per D-04).
3. Update `RenderWindow` method — change BOTH loops from `classify.AllClasses()` to `b.layers`:
Loop 1 (UpdateTarget): Change from:
```go
for _, class := range classify.AllClasses() {
count := snap.Counts[class]
b.layers[class].UpdateTarget(count, maxCount)
}
```
To:
```go
for class, layer := range b.layers {
count := snap.Counts[class]
layer.UpdateTarget(count, maxCount)
}
```
Loop 2 (Render): Change from:
```go
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
}
```
To:
```go
for _, layer := range b.layers {
sample := layer.AdvanceSample()
gainL, gainR := PanGains(layer.Config.Pan)
sumL += sample * b.gainPerLayer * gainL
sumR += sample * b.gainPerLayer * gainR
}
```
Note: use `b.gainPerLayer` (the instance field) NOT the package constant `GainPerLayer`.
4. Update the `RenderWindow` doc comment to remove "Per D-10: each layer gets GainPerLayer (1/11)" — replace with "Each layer gets 1/N of the total gain where N is the number of layers."
5. Remove the `classify` import from bank.go ONLY IF it is no longer used. After the changes, `classify.TrafficClass` is still used in the `cfgs` parameter type and `b.layers` map type, and `classify.WindowSnapshot` is used in `RenderWindow`. So the import stays. However, `classify.AllClasses()` is no longer called — verify it is not referenced anywhere in bank.go.
6. In encode/mp3.go, change the single `NewBank` call from:
```go
bank := synth.NewBank(1.0)
```
To:
```go
bank := synth.NewBank(1.0, synth.ClassFreqConfigs)
```
This preserves v1.0 behavior exactly.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go build ./... && go vet ./synth/... ./encode/...</automated>
</verify>
<acceptance_criteria>
- synth/bank.go OscillatorBank struct contains `gainPerLayer float64`
- synth/bank.go NewBank signature is `func NewBank(tau float64, cfgs map[classify.TrafficClass]FreqConfig) *OscillatorBank`
- synth/bank.go NewBank contains `gainPerLayer: 1.0 / float64(len(cfgs))`
- synth/bank.go NewBank iterates `for class, cfg := range cfgs` (NOT classify.AllClasses())
- synth/bank.go RenderWindow UpdateTarget loop uses `for class, layer := range b.layers`
- synth/bank.go RenderWindow render loop uses `for _, layer := range b.layers`
- synth/bank.go RenderWindow render loop uses `b.gainPerLayer` (NOT the GainPerLayer constant)
- synth/bank.go does NOT contain `classify.AllClasses()`
- encode/mp3.go contains `synth.NewBank(1.0, synth.ClassFreqConfigs)`
- `go build ./...` exits 0
</acceptance_criteria>
<done>NewBank accepts injected config map. GainPerLayer is dynamic. RenderWindow iterates b.layers in both loops. encode/mp3.go passes ClassFreqConfigs as default. Project compiles.</done>
</task>
<task type="auto">
<name>Task 2: Update tests for new NewBank signature and dynamic gain</name>
<files>synth/bank_test.go, synth/config_test.go</files>
<read_first>synth/bank_test.go, synth/config_test.go, synth/bank.go, synth/config.go</read_first>
<action>
Per Pitfall 4 from research, update all tests that call NewBank or reference NumLayers:
1. In synth/bank_test.go, update ALL `NewBank(...)` calls to pass `ClassFreqConfigs`:
- `TestNewBankHas14Layers`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`. Keep the assertion `len(b.layers) != 14` and the loop verifying each class has a layer. (This test uses internal package access since it's `package synth`.)
- `TestRenderWindowOutputLength`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestRenderWindowSilentWhenNoTraffic`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestRenderWindowNonZeroWithTraffic`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
- `TestMixerNoClip`: Change `NewBank(0.01)` to `NewBank(0.01, ClassFreqConfigs)`. Also change `classify.AllClasses()` in the count setup loop to iterate `ClassFreqConfigs` keys instead:
```go
for class := range ClassFreqConfigs {
counts[class] = 1000
}
```
And update TotalPackets to `int64(len(ClassFreqConfigs)) * 1000`.
- `TestStereoPan`: Change `NewBank(0.01)` to `NewBank(0.01, ClassFreqConfigs)`.
- `TestMultipleWindowsEMAConvergence`: Change `NewBank(1.0)` to `NewBank(1.0, ClassFreqConfigs)`.
2. Add a new test `TestNewBankDynamicGain` to synth/bank_test.go:
```go
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)
}
}
```
3. Add a test `TestNewBankCustomConfigNoClip` to synth/bank_test.go to verify no-clip with a non-14 config:
```go
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])
}
}
}
}
```
4. In synth/config_test.go, update `TestNumLayersMatchesAllClasses`:
Change from asserting `synth.NumLayers != len(classify.AllClasses())` to asserting `len(synth.ClassFreqConfigs) == len(classify.AllClasses())`:
```go
func TestNumLayersMatchesAllClasses(t *testing.T) {
if len(synth.ClassFreqConfigs) != len(classify.AllClasses()) {
t.Errorf("ClassFreqConfigs has %d entries but AllClasses() has %d entries",
len(synth.ClassFreqConfigs), len(classify.AllClasses()))
}
}
```
This preserves the invariant that every built-in class has a config entry, without depending on the NumLayers constant.
</action>
<verify>
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... ./encode/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- synth/bank_test.go contains no calls to `NewBank(1.0)` or `NewBank(0.01)` — all calls have two arguments
- synth/bank_test.go contains `TestNewBankDynamicGain` with assertion `b.gainPerLayer != expected`
- synth/bank_test.go contains `TestNewBankCustomConfigNoClip`
- synth/bank_test.go TestMixerNoClip iterates `ClassFreqConfigs` keys (not `classify.AllClasses()`)
- synth/config_test.go TestNumLayersMatchesAllClasses asserts `len(synth.ClassFreqConfigs) == len(classify.AllClasses())`
- synth/config_test.go TestNumLayersMatchesAllClasses does NOT reference `synth.NumLayers`
- `go test ./synth/... ./encode/...` exits 0
- `go test ./...` exits 0
</acceptance_criteria>
<done>All tests updated to new NewBank two-argument signature. Dynamic gain verified with custom config maps. No-clip test passes with non-14 class counts. TestNumLayersMatchesAllClasses updated. Full test suite green.</done>
</task>
</tasks>
<verification>
- `go test ./... -v` — full suite passes with no failures
- `go vet ./...` — no warnings
- `go build ./...` — compiles cleanly
- grep confirms no remaining `classify.AllClasses()` in bank.go
- grep confirms no remaining single-arg `NewBank(` calls in production or test code
</verification>
<success_criteria>
- NewBank accepts (tau, cfgs) — no global state dependency
- GainPerLayer computed as 1.0/len(cfgs) — correct for any class count
- RenderWindow iterates b.layers in both loops — no classify.AllClasses() calls
- encode.RunSynthesis passes ClassFreqConfigs — v1.0 behavior preserved
- No-clip guarantee holds for 2-class, 3-class, and 14-class configs
- Full test suite green (synth + encode + all other packages)
</success_criteria>
<output>
After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-02-SUMMARY.md`
</output>