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
|
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
|
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
|
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
|
### 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
|
**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 |
|
| 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 |
|
| 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 |
|
| 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 | - |
|
| 6. Config Package and Sound Overrides | v1.1 | 0/? | Not started | - |
|
||||||
| 7. Custom Rules and Print-Config | 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