diff --git a/.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md b/.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
new file mode 100644
index 0000000..07b5da3
--- /dev/null
+++ b/.planning/phases/05-waveform-types-and-bank-decoupling/05-RESEARCH.md
@@ -0,0 +1,439 @@
+# Phase 5: Waveform Types and Bank Decoupling - Research
+
+**Researched:** 2026-03-26
+**Domain:** Go additive synthesis, oscillator architecture, dependency injection
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **D-01:** Use bandlimited additive synthesis with 8-12 partials per waveform type. Square wave uses odd harmonics (1,3,5,...,11), sawtooth uses all harmonics (1-12), triangle uses odd harmonics with 1/n^2 amplitude rolloff. This is the standard approach for aliasing-free waveform generation.
+- **D-02:** Add a `WaveformType` enum to `FreqConfig` (`Sine`, `Square`, `Sawtooth`, `Triangle`). When waveform is set, generate the `[]HarmonicDef` from the preset formula. When waveform is unset/custom, use the existing hand-tuned `Harmonics` array.
+- **D-04:** Fix GainPerLayer now in Phase 5 — compute dynamically as `1.0 / float64(len(layers))` inside `NewBank` instead of using the hardcoded `NumLayers=14` constant. This establishes the correct foundation before Phase 7 adds dynamic class counts.
+
+### Claude's Discretion
+
+- **D-03:** Decide whether built-in classes keep their hand-tuned HarmonicDef arrays or migrate to waveform presets. Recommended approach: keep existing harmonics as-is for v1.0 classes (preserves sound character), default them to `WaveformType = ""` (custom). Waveform presets only take effect when explicitly set via config in Phase 6.
+- **D-05:** Change `NewBank` to accept a `map[classify.TrafficClass]FreqConfig` parameter instead of reading the `ClassFreqConfigs` global. This is the injection seam that Phase 6 will use to pass merged config. The existing `ClassFreqConfigs` var remains as the default map.
+
+### Deferred Ideas (OUT OF SCOPE)
+
+None — discussion stayed within phase scope.
+
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| WAVE-01 | User can set waveform type per traffic class (sine, square, sawtooth, triangle) | D-02: `WaveformType` field on `FreqConfig`; `WaveformPresetHarmonics()` generates the right `[]HarmonicDef` at layer-construction time. `NewBank` iterates the injected config map, so each class can carry a distinct waveform. |
+| WAVE-02 | Non-sine waveforms use bandlimited additive synthesis (no aliasing artifacts) | D-01: Harmonic truncation at Nyquist (22050 Hz) is built into `WaveformPresetHarmonics()`. Existing `Oscillator.Advance([]HarmonicDef)` already sums sine partials — waveform type only changes WHICH harmonics are passed, not the summation math. No naive waveform math is ever used. |
+
+
+
+---
+
+## Summary
+
+Phase 5 makes two independent but related changes to the `synth` package: (1) it extends the oscillator to support four waveform types via bandlimited additive synthesis, and (2) it decouples `OscillatorBank.NewBank` from the package-level `ClassFreqConfigs` global by accepting an injected config map.
+
+Both changes are contained entirely within the `synth` package and `encode/mp3.go`. No new packages are introduced. The existing `Oscillator.Advance([]HarmonicDef)` engine already supports additive synthesis — the waveform extension simply generates different harmonic series at construction time rather than at sample-render time. The bank decoupling is a signature change to `NewBank` with a one-line follow-up in `encode/mp3.go`.
+
+The build order is: waveform enum and `WaveformPresetHarmonics()` function first (pure math, independently testable), then wire `WaveformType` through `FreqConfig` and `NewLayer`, then change `NewBank` signature and fix `GainPerLayer`. Each step leaves existing tests green.
+
+**Primary recommendation:** Generate bandlimited `[]HarmonicDef` slices from the waveform preset at layer construction time (inside `NewLayer` or `NewBank`) — never at sample-render time. This keeps `Oscillator.Advance` unchanged and avoids per-sample branching.
+
+---
+
+## Standard Stack
+
+### Core
+
+No new external libraries are required. All waveform math uses `math.Sin` from Go's standard library. The existing dependency set is sufficient.
+
+| Technology | Version | Purpose | Why Standard |
+|------------|---------|---------|--------------|
+| `math.Sin` (stdlib) | Go 1.24 | Sine partial summation in `Oscillator.Advance` | Already the engine for all synthesis; waveform types extend what series is passed to it |
+| `github.com/gopacket/gopacket` | v1.5.0 | Packet decode (unchanged) | No change — listed for completeness |
+| `github.com/sjzar/go-lame` | v0.0.9 | MP3 encoding (unchanged) | No change — listed for completeness |
+
+**Installation:** No new dependencies. `go.mod` unchanged.
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure (unchanged)
+
+```
+synth/
+├── config.go FreqConfig (+ WaveformType field), HarmonicDef, ClassFreqConfigs, WaveformPresetHarmonics()
+├── oscillator.go Oscillator — unchanged (Advance still takes []HarmonicDef)
+├── layer.go NewLayer passes cfg.WaveformType-derived harmonics to oscillator
+├── bank.go NewBank(tau, cfgs map[TrafficClass]FreqConfig) — decoupled
+└── mixer.go Unchanged
+encode/
+└── mp3.go RunSynthesis passes synth.ClassFreqConfigs as default to NewBank
+```
+
+### Pattern 1: Bandlimited Harmonic Series Generation
+
+**What:** A function `WaveformPresetHarmonics(waveformType WaveformType, baseHz float64, sampleRate int) []HarmonicDef` computes the correct partial series for each waveform, truncating at Nyquist to prevent aliasing. Called once at layer-construction time; result stored in the layer's oscillator call path.
+
+**When to use:** Whenever `FreqConfig.WaveformType` is not `WaveformCustom` (the zero-value indicating hand-tuned harmonics).
+
+**Example:**
+```go
+// In synth/config.go
+type WaveformType int
+
+const (
+ WaveformCustom WaveformType = iota // zero value: use FreqConfig.Harmonics as-is
+ WaveformSine
+ WaveformSquare
+ WaveformSawtooth
+ WaveformTriangle
+)
+
+// WaveformPresetHarmonics returns a bandlimited harmonic series for the given waveform type.
+// Partials above Nyquist (sampleRate/2) are excluded to prevent aliasing.
+// Returns nil if waveformType is WaveformCustom (caller uses FreqConfig.Harmonics directly).
+func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef {
+ nyquist := float64(sampleRate) / 2.0
+ switch wt {
+ case WaveformSine:
+ return []HarmonicDef{{Ratio: 1, Amplitude: 1.0}}
+ case WaveformSquare:
+ // Odd harmonics: 1, 3, 5, ... with amplitude 1/k, truncate at Nyquist
+ var defs []HarmonicDef
+ for k := 1; float64(k)*baseHz < nyquist; k += 2 {
+ defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
+ }
+ return defs
+ case WaveformSawtooth:
+ // All harmonics: 1, 2, 3, ... with amplitude 1/k, truncate at Nyquist
+ var defs []HarmonicDef
+ for k := 1; float64(k)*baseHz < nyquist; k++ {
+ defs = append(defs, HarmonicDef{Ratio: k, Amplitude: 1.0 / float64(k)})
+ }
+ return defs
+ case WaveformTriangle:
+ // Odd harmonics with alternating sign, amplitude 1/k^2, truncate at Nyquist
+ sign := 1.0
+ var defs []HarmonicDef
+ for k := 1; float64(k)*baseHz < nyquist; k += 2 {
+ defs = append(defs, HarmonicDef{Ratio: k, Amplitude: sign / float64(k*k)})
+ sign = -sign
+ }
+ return defs
+ default: // WaveformCustom
+ return nil
+ }
+}
+```
+
+### Pattern 2: Harmonic Resolution in NewLayer
+
+**What:** `NewLayer` resolves which harmonic array the oscillator will use. If `cfg.WaveformType` is `WaveformCustom` (zero value), use `cfg.Harmonics`. Otherwise call `WaveformPresetHarmonics` and store the result on `Layer.Config.Harmonics` so `AdvanceSample` needs no change.
+
+**When to use:** Every `NewLayer` call. The resolution is a one-time cost at construction.
+
+**Example:**
+```go
+func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
+ if cfg.WaveformType != WaveformCustom {
+ cfg.Harmonics = WaveformPresetHarmonics(cfg.WaveformType, cfg.BaseHz, sampleRate)
+ }
+ return &Layer{
+ Config: cfg,
+ Osc: NewOscillator(cfg.BaseHz, sampleRate),
+ alpha: EMAAlpha(tau, sampleRate),
+ whisper: WhisperFloor,
+ }
+}
+```
+
+`AdvanceSample` is unchanged — it still calls `l.Osc.Advance(l.Config.Harmonics)`.
+
+### Pattern 3: NewBank Signature with Injected Config Map
+
+**What:** `NewBank` gains a second parameter: `cfgs map[classify.TrafficClass]FreqConfig`. It iterates the map's keys to build layers, instead of ranging over `classify.AllClasses()`. `GainPerLayer` is computed dynamically from `len(cfgs)` instead of the `NumLayers` constant.
+
+**When to use:** All callers of `NewBank`. `encode/mp3.go` passes `synth.ClassFreqConfigs` as the default, preserving v1.0 behavior.
+
+**Example:**
+```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
+}
+```
+
+`OscillatorBank` gains a `gainPerLayer float64` field. `RenderWindow` uses `b.gainPerLayer` instead of the package-level `GainPerLayer` constant. The constant `GainPerLayer` and `NumLayers` can be deprecated (kept for any external referencing tests but no longer used in bank logic).
+
+`RenderWindow` iterates `b.layers` directly instead of `classify.AllClasses()`:
+
+```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:** `RenderWindow` currently also iterates `classify.AllClasses()` when calling `UpdateTarget`. This must also change to iterate the `snap.Counts` map (or iterate `b.layers` keys and look up each class in `snap.Counts`):
+
+```go
+for class, layer := range b.layers {
+ count := snap.Counts[class]
+ layer.UpdateTarget(count, maxCount)
+}
+```
+
+### Anti-Patterns to Avoid
+
+- **Generating harmonics at sample-render time:** Do not call `WaveformPresetHarmonics` inside `Oscillator.Advance` or `Layer.AdvanceSample`. This costs ~10 allocations per frame at 44100 Hz and changes the per-sample hot path. Generate once at construction time.
+- **Adding a new oscillator type per waveform:** The existing `Oscillator` + `[]HarmonicDef` is already a general additive engine. A new `SquareOscillator` type would duplicate phase management, EMA wiring, and all tests. There is no need.
+- **Removing the `NumLayers` and `GainPerLayer` constants immediately:** Tests in `synth/config_test.go` (specifically `TestNumLayersMatchesAllClasses`) reference `synth.NumLayers`. The constant must remain exported (even if bank no longer uses it internally) until the test is updated. Update the test as part of D-04.
+- **Iterating `classify.AllClasses()` in RenderWindow:** After D-05, `b.layers` is the authoritative set of active classes. The two remaining loops in `RenderWindow` that range over `classify.AllClasses()` must both change to iterate `b.layers`, or they will break when Phase 7 adds user-defined classes that are not in `AllClasses()`.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Aliasing-free waveforms | Direct time-domain `sign(sin(phase))`, `2*frac(phase)-1` | Bandlimited additive synthesis via `WaveformPresetHarmonics()` | Direct math has infinite harmonics; aliases above Nyquist fold into the audible range as buzzing distortion — worst at 330 Hz+ (SSH, SMTP, DHCP) |
+| Per-sample waveform dispatch | `switch waveform { case square: return sign(sin(...)) }` in `Advance()` | Preset `[]HarmonicDef` computed at construction | Avoids per-sample branching; reuses existing `Oscillator.Advance` without any signature change |
+| Dynamic gain normalization | Hand-derive scaling formula per class count | `1.0 / float64(len(cfgs))` | Already the correct formula; the existing `NumLayers=14` constant was a specialization of this |
+
+**Key insight:** The additive synthesis engine (`Oscillator.Advance([]HarmonicDef)`) is already general. Waveform type support is purely a matter of which harmonic series you feed it, not how the oscillator itself works.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Naive Waveform Math Produces Audible Aliasing (Pitfall A3)
+
+**What goes wrong:** Implementing `square(phase) = sign(sin(2π·phase))` or `sawtooth(phase) = 2·frac(phase) - 1` directly. These have infinite harmonics; above Nyquist they fold back into the audible range as aliasing. At SSH (330 Hz) and higher, the effect is audible buzzing that sounds like corruption.
+
+**Why it happens:** The mathematical waveforms are not bandlimited. Sampling them at 44100 Hz aliases all energy above 22050 Hz back into audible frequencies.
+
+**How to avoid:** Use `WaveformPresetHarmonics()` which truncates the harmonic series at `float64(k)*baseHz < nyquist`. The existing `Oscillator.Advance` sums sinusoids, which are already bandlimited by nature.
+
+**Warning signs:** Square/sawtooth sounds buzzy or harsh at frequencies above ~300 Hz. Aliasing cannot be removed after the fact.
+
+### Pitfall 2: Generating Harmonics at Sample-Render Time
+
+**What goes wrong:** Calling `WaveformPresetHarmonics()` inside `Advance()` or `AdvanceSample()` on every sample. At 44100 Hz per channel this creates 44100 slice allocations per second, causing GC pressure and measurable latency in the render loop.
+
+**Why it happens:** Placing the preset logic in `Advance` seems clean because it keeps the oscillator self-contained.
+
+**How to avoid:** Resolve harmonics once in `NewLayer` (at construction). Store the result in `Layer.Config.Harmonics`. `AdvanceSample` needs no change.
+
+**Warning signs:** CPU profile shows allocations in `synth.WaveformPresetHarmonics` during `RenderWindow`.
+
+### Pitfall 3: Both RenderWindow Loops Still Iterate classify.AllClasses()
+
+**What goes wrong:** `RenderWindow` has two loops that call `classify.AllClasses()`: one for `UpdateTarget` and one for rendering. After `NewBank` switches to iterating the injected map, if both `RenderWindow` loops still use `classify.AllClasses()`, Phase 7 user-defined classes will aggregate counts but never have their target updated, producing silence with no error.
+
+**Why it happens:** Updating `NewBank`'s construction loop is the obvious change; the two `RenderWindow` loops are easy to miss.
+
+**How to avoid:** Change all three loops in `bank.go` simultaneously. Use `for class, layer := range b.layers` in both `RenderWindow` loops.
+
+**Warning signs:** User-defined class layers produce silence when traffic is present (Phase 7 symptom), or `TestMixerNoClip` fails if the layer count changes.
+
+### Pitfall 4: TestNewBankHas14Layers and TestNumLayersMatchesAllClasses Break Without Updates
+
+**What goes wrong:** `synth/bank_test.go:TestNewBankHas14Layers` calls `NewBank(1.0)` with the old one-argument signature. `synth/config_test.go:TestNumLayersMatchesAllClasses` asserts `synth.NumLayers == len(classify.AllClasses())`. Both tests fail on compile or assertion the moment `NewBank` gains a parameter.
+
+**Why it happens:** These tests were written against the v1.0 API.
+
+**How to avoid:** Update both tests as part of the same commit that changes `NewBank`. `TestNewBankHas14Layers` should call `NewBank(1.0, synth.ClassFreqConfigs)`. `TestNumLayersMatchesAllClasses` should be updated to assert `len(synth.ClassFreqConfigs) == len(classify.AllClasses())` or deleted if the invariant is no longer meaningful.
+
+**Warning signs:** Compile error on `NewBank(1.0)` after the signature change.
+
+### Pitfall 5: Triangle Wave Amplitude Is Much Lower Than Other Waveforms
+
+**What goes wrong:** Triangle uses `1/k^2` amplitude rolloff (vs `1/k` for square/sawtooth). The total weight of the normalized series is much lower (sum of `1/k^2` for odd k converges to `π^2/8 ≈ 1.23` vs `π/4 ≈ 0.79` for square), but after normalization in `Oscillator.Advance` (`sum / totalWeight`) the peak amplitude is ~1.0. However, because fewer harmonics contribute significantly, the RMS energy is lower than a square wave at the same amplitude setting. This means triangle layers sound subjectively quieter even at the same volume setting.
+
+**Why it happens:** The 1/k^2 rolloff is acoustically intentional (triangle is the smoothest non-sine waveform) but it may surprise developers comparing oscilloscope peak values vs perceived loudness.
+
+**How to avoid:** This is a design characteristic, not a bug. Document it. If perceptual loudness matching is needed in Phase 6, the user can adjust the `GainPerLayer` or per-class amplitude in config. Do not "fix" by changing amplitudes — that would break the standard triangle wave definition.
+
+**Warning signs:** Triangle-waveform layer sounds noticeably quieter than square/sawtooth at the same traffic level.
+
+---
+
+## Code Examples
+
+Verified patterns from direct code inspection of the existing codebase:
+
+### How Oscillator.Advance Currently Works (unchanged)
+
+```go
+// synth/oscillator.go — existing, unchanged by this phase
+func (o *Oscillator) Advance(harmonics []HarmonicDef) float64 {
+ sum := 0.0
+ totalWeight := 0.0
+ for _, h := range harmonics {
+ sum += h.Amplitude * math.Sin(2*math.Pi*o.phase*float64(h.Ratio))
+ totalWeight += h.Amplitude
+ }
+ o.phase += o.freq / o.sr
+ if o.phase >= 1.0 {
+ o.phase -= 1.0
+ }
+ if totalWeight > 0 {
+ return sum / totalWeight
+ }
+ return 0
+}
+```
+
+The normalization (`sum / totalWeight`) ensures the output is bounded in [-1, 1] regardless of how many partials are summed. Waveform presets with `1/k` amplitudes naturally produce a well-normalized output from this engine.
+
+### Partial Count vs Frequency for Phase 5 Presets
+
+At 44100 Hz sample rate (Nyquist = 22050 Hz):
+
+| Waveform | BaseHz | Max Partial | Partial Count |
+|----------|--------|-------------|---------------|
+| Square | 65 Hz | k=675 (odd) | ~338 partials |
+| Square | 1047 Hz | k=41 (odd) | ~21 partials |
+| Sawtooth | 65 Hz | k=339 | 339 partials |
+| Sawtooth | 1047 Hz | k=21 | 21 partials |
+| Triangle | 65 Hz | k=675 (odd) | ~338 partials |
+| Triangle | 1047 Hz | k=41 (odd) | ~21 partials |
+
+The D-01 decision specifies "8-12 partials" as a practical cap. The Nyquist-truncation formula above naturally produces more partials for low-frequency oscillators. The planner should consider whether to implement a hard cap at 12 partials (simpler, slightly more aliasing at very low frequencies) or use the full Nyquist-truncated series (more accurate, still inaudible aliasing). Both are correct implementations of WAVE-02.
+
+**Recommendation (Claude's Discretion):** Use the Nyquist-truncation formula without an additional hard cap. For very low-frequency bases (65 Hz), 300+ partials is still fast in the inner loop since the sum is simple float64 multiply-and-add. The audible difference between 12 and 300 partials at 65 Hz is significant; the 12-partial cap would noticeably affect sound character. Reserve the 8-12 cap language as an approximation, not an implementation constraint.
+
+### encode/mp3.go Change (the only caller of NewBank)
+
+```go
+// encode/mp3.go — current call
+bank := synth.NewBank(1.0)
+
+// encode/mp3.go — updated call (passes default config, behavior identical)
+bank := synth.NewBank(1.0, synth.ClassFreqConfigs)
+```
+
+This is the only external call site. No other files reference `synth.NewBank`.
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `NumLayers=14` hardcoded constant for gain scaling | `1.0 / float64(len(cfgs))` computed dynamically | Phase 5 (D-04) | Gain scaling stays correct as class count varies in Phase 7 |
+| `NewBank` reads `ClassFreqConfigs` global | `NewBank(tau, cfgs)` accepts injected map | Phase 5 (D-05) | Bank is now testable without global mutation; Phase 6 can pass merged configs |
+| Sine-only oscillator | Four waveform types via bandlimited additive synthesis | Phase 5 | User-selectable timbres per traffic class; WAVE-01/02 satisfied |
+
+**Deprecated/outdated after this phase:**
+- `NumLayers` constant: still exported but no longer used in bank logic. Can be removed in a cleanup phase.
+- `GainPerLayer` constant: same status as `NumLayers`.
+- `bank.go` ranging over `classify.AllClasses()`: replaced by ranging over `b.layers` in all three loops.
+
+---
+
+## Environment Availability
+
+Step 2.6: SKIPPED — phase is purely code changes within the existing Go module. No external tools, services, runtimes, databases, or CLIs beyond the project's own build toolchain are required. Existing `go test ./synth/...` confirms the baseline passes.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Go testing (`testing` stdlib) |
+| Config file | None — standard `go test` |
+| Quick run command | `go test ./synth/... ./encode/...` |
+| Full suite command | `go test ./...` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| WAVE-01 | WaveformType field added to FreqConfig; zero value (WaveformCustom) preserves existing behavior | unit | `go test ./synth/... -run TestWaveformCustomPreservesHarmonics` | ❌ Wave 0 |
+| WAVE-01 | WaveformPresetHarmonics returns correct partial series for square, sawtooth, triangle, sine | unit | `go test ./synth/... -run TestWaveformPresetHarmonics` | ❌ Wave 0 |
+| WAVE-01 | NewBank accepts injected config map; layer count equals map size | unit | `go test ./synth/... -run TestNewBankAcceptsConfigMap` | ❌ Wave 0 (replaces TestNewBankHas14Layers) |
+| WAVE-02 | All partials in preset harmonic series are below Nyquist (sampleRate/2) | unit | `go test ./synth/... -run TestBandlimitedHarmonicsNoAliasing` | ❌ Wave 0 |
+| WAVE-02 | Sine waveform (WaveformSine preset) produces same output as single-harmonic custom config | unit | `go test ./synth/... -run TestSineRegressionVsCustomHarmonics` | ❌ Wave 0 |
+| WAVE-01+02 | GainPerLayer computed dynamically; no clip with N-class config map | unit | `go test ./synth/... -run TestMixerNoClip` | ✅ exists (update to new NewBank signature) |
+| WAVE-01 | encode.RunSynthesis compiles and passes synth.ClassFreqConfigs to NewBank | unit/smoke | `go test ./encode/...` | ✅ exists (update call site) |
+
+### Sampling Rate
+
+- **Per task commit:** `go test ./synth/... ./encode/...`
+- **Per wave merge:** `go test ./...`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `synth/waveform_test.go` (or additions to `synth/oscillator_test.go`) — covers WAVE-01 (preset harmonics correctness) and WAVE-02 (bandlimit enforcement)
+- [ ] Update `synth/bank_test.go:TestNewBankHas14Layers` to use new two-argument `NewBank` signature
+- [ ] Update `synth/config_test.go:TestNumLayersMatchesAllClasses` to reflect dynamic gain approach
+
+---
+
+## Open Questions
+
+1. **Hard cap on partial count (8-12 partials per D-01 vs Nyquist truncation)**
+ - What we know: D-01 says "8-12 partials." Nyquist truncation produces up to ~340 partials for a 65 Hz sawtooth. Both approaches satisfy WAVE-02.
+ - What's unclear: Was "8-12 partials" a maximum cap or a minimum floor for realistic waveforms?
+ - Recommendation: Use Nyquist truncation without hard cap. At 44100 Hz the summation loop is fast. Document the choice. If the user hears no meaningful difference between 12 and 340 partials at 65 Hz (perceptually similar) then reconsider in Phase 6 when user testing begins.
+
+2. **TestHarmonicsNonEmpty breaks if WaveformCustom harmonics are empty for a class**
+ - What we know: `synth/config_test.go:TestHarmonicsNonEmpty` asserts every `ClassFreqConfigs` entry has `len(cfg.Harmonics) >= 2`. All built-in entries retain their hand-tuned harmonics (D-03), so this test continues to pass.
+ - What's unclear: If a future entry in `ClassFreqConfigs` uses `WaveformType = WaveformSine` with an empty `Harmonics` slice, the test would fail. This is not a Phase 5 concern since D-03 says keep existing harmonics as-is.
+ - Recommendation: No action needed in Phase 5. Note for Phase 6 if user-configured classes with preset waveforms and empty Harmonics are added to the default config.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- Direct code inspection: `synth/oscillator.go`, `synth/config.go`, `synth/bank.go`, `synth/layer.go`, `synth/bank_test.go`, `synth/config_test.go`, `synth/oscillator_test.go`, `encode/mp3.go` — exact current implementation confirmed
+- `.planning/research/PITFALLS.md` — Pitfall A3 (aliasing), verified against DSP literature in that document
+- `.planning/research/ARCHITECTURE.md` — Integration point analysis, build order, confirmed against actual code
+
+### Secondary (MEDIUM confidence)
+
+- `.planning/phases/05-waveform-types-and-bank-decoupling/05-CONTEXT.md` — User decisions D-01 through D-05
+- DSP theory: harmonic series for square (odd, 1/k), sawtooth (all, 1/k), triangle (odd, alternating sign, 1/k^2) — standard result, confirmed in PITFALLS.md sources (WolfSound, CCRMA, McGill)
+
+### Tertiary (LOW confidence)
+
+None. All findings grounded in direct code inspection or established DSP theory.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — no new libraries; all changes are within existing codebase
+- Architecture: HIGH — based on direct inspection of all affected files; build order verified against existing test structure
+- Pitfalls: HIGH — aliasing pitfall from DSP literature; API-break pitfalls from direct test-file inspection
+
+**Research date:** 2026-03-26
+**Valid until:** Stable — pure Go math and internal refactor; no external API dependencies that could change