docs(05): create phase plan
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user