diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1c9396e..0ea81fb 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/05-waveform-types-and-bank-decoupling/05-01-PLAN.md b/.planning/phases/05-waveform-types-and-bank-decoupling/05-01-PLAN.md new file mode 100644 index 0000000..9dea026 --- /dev/null +++ b/.planning/phases/05-waveform-types-and-bank-decoupling/05-01-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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) +``` + + + + + + + Task 1: Add WaveformType enum and WaveformPresetHarmonics function + synth/config.go, synth/waveform_test.go + synth/config.go, synth/oscillator.go, synth/layer.go + + - 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 + + +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. + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestWaveformPreset|TestBandlimited" -v + + + - 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) + + 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 + + + + Task 2: Wire waveform resolution into NewLayer + synth/layer.go, synth/waveform_test.go + synth/layer.go, synth/config.go, synth/waveform_test.go + +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. + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... -v -count=1 + + + - 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) + + 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. + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-01-SUMMARY.md` + diff --git a/.planning/phases/05-waveform-types-and-bank-decoupling/05-02-PLAN.md b/.planning/phases/05-waveform-types-and-bank-decoupling/05-02-PLAN.md new file mode 100644 index 0000000..8b5a49d --- /dev/null +++ b/.planning/phases/05-waveform-types-and-bank-decoupling/05-02-PLAN.md @@ -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\\(" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 +``` + + + + + + + Task 1: Decouple NewBank and fix GainPerLayer + synth/bank.go, encode/mp3.go + synth/bank.go, synth/config.go, encode/mp3.go, synth/layer.go + +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. + + + cd /home/dev/workspace/yoloyolo && go build ./... && go vet ./synth/... ./encode/... + + + - 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 + + NewBank accepts injected config map. GainPerLayer is dynamic. RenderWindow iterates b.layers in both loops. encode/mp3.go passes ClassFreqConfigs as default. Project compiles. + + + + Task 2: Update tests for new NewBank signature and dynamic gain + synth/bank_test.go, synth/config_test.go + synth/bank_test.go, synth/config_test.go, synth/bank.go, synth/config.go + +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. + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... ./encode/... -v -count=1 + + + - 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 + + 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. + + + + + +- `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 + + + +- 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) + + + +After completion, create `.planning/phases/05-waveform-types-and-bank-decoupling/05-02-SUMMARY.md` +