docs(02): create phase plan — 3 plans across 3 waves
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 02-audio-synthesis-engine
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- synth/bank.go
|
||||
- synth/mixer.go
|
||||
- synth/bank_test.go
|
||||
- synth/mixer_test.go
|
||||
autonomous: true
|
||||
requirements: [SYNTH-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "OscillatorBank updates all 11 layers from a WindowSnapshot and renders stereo PCM frames"
|
||||
- "Mixer sums 11 layers with fixed 1/11 gain per layer — peak sum never exceeds 1.0"
|
||||
- "Stereo panning uses constant-power pan law producing distinct L/R values for non-center sources"
|
||||
- "Full render pipeline: WindowSnapshot -> OscillatorBank.RenderWindow -> [][2]float64 stereo frames"
|
||||
artifacts:
|
||||
- path: "synth/bank.go"
|
||||
provides: "OscillatorBank with NewBank, RenderWindow methods"
|
||||
contains: "func (b *OscillatorBank) RenderWindow"
|
||||
- path: "synth/mixer.go"
|
||||
provides: "panGains constant-power function and float64-to-int16 conversion"
|
||||
contains: "func PanGains"
|
||||
key_links:
|
||||
- from: "synth/bank.go"
|
||||
to: "synth/layer.go"
|
||||
via: "Bank holds map[TrafficClass]*Layer"
|
||||
pattern: "map\\[classify\\.TrafficClass\\]\\*Layer"
|
||||
- from: "synth/bank.go"
|
||||
to: "synth/config.go"
|
||||
via: "Reads ClassFreqConfigs to initialize layers"
|
||||
pattern: "ClassFreqConfigs"
|
||||
- from: "synth/mixer.go"
|
||||
to: "synth/bank.go"
|
||||
via: "StereoFramesToInt16Bytes converts bank output to encoder-ready bytes"
|
||||
pattern: "StereoFramesToInt16Bytes"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the oscillator bank (11 layers driven by WindowSnapshot) and stereo mixer with int16 PCM conversion. This completes SYNTH-03 (mixing without distortion) and prepares the PCM output format needed by the MP3 encoder in Plan 03.
|
||||
|
||||
Purpose: The bank is the core synthesis engine that transforms traffic snapshots into stereo audio frames. The mixer ensures no clipping via fixed gain and provides constant-power stereo panning.
|
||||
|
||||
Output: `synth/bank.go`, `synth/mixer.go` with 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/phases/02-audio-synthesis-engine/02-CONTEXT.md
|
||||
@.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md
|
||||
@.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md
|
||||
|
||||
@classify/types.go
|
||||
|
||||
<interfaces>
|
||||
<!-- From synth/config.go (created in Plan 01) -->
|
||||
From synth/config.go:
|
||||
```go
|
||||
const (
|
||||
SampleRate = 44100
|
||||
WindowMs = 500
|
||||
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
||||
NumLayers = 11
|
||||
GainPerLayer = 1.0 / float64(NumLayers)
|
||||
WhisperFloor = 0.03
|
||||
)
|
||||
|
||||
type HarmonicDef struct {
|
||||
Ratio int
|
||||
Amplitude float64
|
||||
}
|
||||
|
||||
type FreqConfig struct {
|
||||
BaseHz float64
|
||||
Harmonics []HarmonicDef
|
||||
Pan float64
|
||||
}
|
||||
|
||||
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... }
|
||||
```
|
||||
|
||||
From synth/oscillator.go:
|
||||
```go
|
||||
func NewOscillator(freq float64, sampleRate int) *Oscillator
|
||||
func (o *Oscillator) Advance(harmonics []HarmonicDef) float64
|
||||
```
|
||||
|
||||
From synth/layer.go:
|
||||
```go
|
||||
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
|
||||
func (l *Layer) UpdateTarget(count int64, maxCount int64)
|
||||
func (l *Layer) AdvanceSample() float64
|
||||
func (l *Layer) CurrentAmp() float64
|
||||
```
|
||||
|
||||
From classify/types.go:
|
||||
```go
|
||||
func AllClasses() []TrafficClass
|
||||
type WindowSnapshot struct {
|
||||
Counts map[TrafficClass]int64
|
||||
TotalPackets int64
|
||||
WindowIndex int
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Stereo mixer utilities</name>
|
||||
<files>synth/mixer.go, synth/mixer_test.go</files>
|
||||
<read_first>
|
||||
- synth/config.go (constants: GainPerLayer, SamplesPerWindow, SampleRate)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 3: constant-power panning, Pattern 4: int16 conversion)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10, D-11, D-12, D-13)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestPanGainsCenter: PanGains(0.0) returns gainL ~= 0.707, gainR ~= 0.707 (cos(pi/4), sin(pi/4))
|
||||
- TestPanGainsFullLeft: PanGains(-1.0) returns gainL ~= 1.0, gainR ~= 0.0
|
||||
- TestPanGainsFullRight: PanGains(1.0) returns gainL ~= 0.0, gainR ~= 1.0
|
||||
- TestPanGainsPowerPreserved: For any pan value, gainL^2 + gainR^2 ~= 1.0 (constant power)
|
||||
- TestStereoFramesToInt16Bytes: [][2]float64{{1.0, -1.0}} produces 4 bytes: int16(32767) LE then int16(-32767) LE
|
||||
- TestStereoFramesToInt16BytesZero: [][2]float64{{0.0, 0.0}} produces 4 zero bytes
|
||||
- TestClampPreventsOverflow: float64 value > 1.0 is clamped to 1.0 before int16 conversion (no wrap-around)
|
||||
</behavior>
|
||||
<action>
|
||||
**synth/mixer.go** — Constant-power pan law (per D-11) and PCM conversion utilities:
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
// PanGains returns left and right channel gains for a pan position p in [-1, 1].
|
||||
// Uses constant-power (equal-power) pan law: cos/sin mapping.
|
||||
// Per D-11: bass frequencies center, mid spread L/R, higher frequencies wider.
|
||||
func PanGains(p float64) (gainL, gainR float64) {
|
||||
angle := (p + 1.0) / 2.0 * math.Pi / 2.0
|
||||
return math.Cos(angle), math.Sin(angle)
|
||||
}
|
||||
|
||||
// StereoFramesToInt16Bytes converts [][2]float64 stereo frames to interleaved
|
||||
// little-endian int16 bytes suitable for go-lame's Write method.
|
||||
// Clamps values to [-1.0, 1.0] before conversion.
|
||||
// Output format: [L0_lo, L0_hi, R0_lo, R0_hi, L1_lo, L1_hi, R1_lo, R1_hi, ...]
|
||||
func StereoFramesToInt16Bytes(frames [][2]float64) []byte {
|
||||
buf := make([]byte, len(frames)*4) // 2 channels * 2 bytes per sample
|
||||
for i, frame := range frames {
|
||||
l := clamp(frame[0])
|
||||
r := clamp(frame[1])
|
||||
binary.LittleEndian.PutUint16(buf[i*4:], uint16(int16(l*32767)))
|
||||
binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(int16(r*32767)))
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func clamp(v float64) float64 {
|
||||
if v > 1.0 {
|
||||
return 1.0
|
||||
}
|
||||
if v < -1.0 {
|
||||
return -1.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
Write tests FIRST (RED), then implementation (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- synth/mixer.go contains `func PanGains(p float64) (gainL, gainR float64)`
|
||||
- synth/mixer.go contains `func StereoFramesToInt16Bytes(frames [][2]float64) []byte`
|
||||
- synth/mixer.go contains `math.Cos(angle), math.Sin(angle)` (constant-power, not linear)
|
||||
- synth/mixer.go contains `func clamp(v float64) float64`
|
||||
- synth/mixer_test.go contains `TestPanGainsCenter`
|
||||
- synth/mixer_test.go contains `TestStereoFramesToInt16Bytes`
|
||||
- `go test ./synth/... -run "TestPan|TestStereo|TestClamp"` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Constant-power pan law produces correct L/R gains for all positions. Stereo frames convert to interleaved int16 LE bytes with clamping. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: OscillatorBank — multi-layer rendering from WindowSnapshot</name>
|
||||
<files>synth/bank.go, synth/bank_test.go</files>
|
||||
<read_first>
|
||||
- synth/config.go (ClassFreqConfigs, GainPerLayer, SamplesPerWindow, NumLayers)
|
||||
- synth/layer.go (NewLayer, UpdateTarget, AdvanceSample)
|
||||
- synth/mixer.go (PanGains)
|
||||
- classify/types.go (AllClasses, WindowSnapshot)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 6: per-window render loop)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10: fixed 1/11 gain)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestNewBankHas11Layers: NewBank() creates exactly 11 layers, one per TrafficClass
|
||||
- TestRenderWindowOutputLength: RenderWindow with any snapshot returns exactly SamplesPerWindow (22050) stereo frames
|
||||
- TestRenderWindowSilentWhenNoTraffic: RenderWindow with empty counts (all zeros, no class ever seen) produces all-zero frames
|
||||
- TestRenderWindowNonZeroWithTraffic: RenderWindow with ClassICMP count=100 produces non-zero L and R samples
|
||||
- TestMixerNoClip: RenderWindow with ALL 11 classes at max count — no frame has |L| > 1.0 or |R| > 1.0 (D-10 guarantees this)
|
||||
- TestStereoPan: RenderWindow with only ClassDHCP (pan=-0.75) — left channel RMS > right channel RMS (wide-left)
|
||||
- TestMultipleWindowsEMAConvergence: RenderWindow called 5 times with same snapshot — later windows have higher amplitude than first (EMA ramp-up)
|
||||
</behavior>
|
||||
<action>
|
||||
**synth/bank.go** — The core synthesis engine. Per D-10, each layer gets GainPerLayer (1/11) of headroom:
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import "github.com/netsynth/netsynth/classify"
|
||||
|
||||
// OscillatorBank holds 11 synthesis layers, one per TrafficClass.
|
||||
// It consumes WindowSnapshot data and renders stereo PCM frames.
|
||||
type OscillatorBank struct {
|
||||
layers map[classify.TrafficClass]*Layer
|
||||
tau float64
|
||||
}
|
||||
|
||||
// NewBank creates an OscillatorBank with one Layer per TrafficClass.
|
||||
// tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel).
|
||||
func NewBank(tau float64) *OscillatorBank {
|
||||
b := &OscillatorBank{
|
||||
layers: make(map[classify.TrafficClass]*Layer, NumLayers),
|
||||
tau: tau,
|
||||
}
|
||||
for _, class := range classify.AllClasses() {
|
||||
cfg := ClassFreqConfigs[class]
|
||||
b.layers[class] = NewLayer(cfg, SampleRate, tau)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
|
||||
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1].
|
||||
func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
|
||||
// Find max count for normalization
|
||||
var maxCount int64
|
||||
for _, count := range snap.Counts {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// Update target amplitudes for all layers
|
||||
for _, class := range classify.AllClasses() {
|
||||
count := snap.Counts[class]
|
||||
b.layers[class].UpdateTarget(count, maxCount)
|
||||
}
|
||||
|
||||
// Render frames
|
||||
frames := make([][2]float64, SamplesPerWindow)
|
||||
for i := range frames {
|
||||
var sumL, sumR float64
|
||||
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
|
||||
}
|
||||
frames[i] = [2]float64{sumL, sumR}
|
||||
}
|
||||
return frames
|
||||
}
|
||||
```
|
||||
|
||||
Key design points:
|
||||
- `GainPerLayer` (1/11) is applied during mixing, not in the layer (per D-10). This ensures 11 max-amplitude layers sum to exactly 1.0.
|
||||
- `classify.AllClasses()` is used for iteration order consistency.
|
||||
- The `AdvanceSample()` call both advances the oscillator phase AND applies EMA smoothing (from layer.go).
|
||||
|
||||
Write tests FIRST (RED), then implementation (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- synth/bank.go contains `func NewBank(tau float64) *OscillatorBank`
|
||||
- synth/bank.go contains `func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64`
|
||||
- synth/bank.go contains `GainPerLayer` (the 1/11 gain applied per layer)
|
||||
- synth/bank.go imports `github.com/netsynth/netsynth/classify`
|
||||
- synth/bank_test.go contains `TestMixerNoClip`
|
||||
- synth/bank_test.go contains `TestStereoPan`
|
||||
- synth/bank_test.go contains `TestRenderWindowOutputLength`
|
||||
- `go test ./synth/...` exits 0 (full synth package passes)
|
||||
</acceptance_criteria>
|
||||
<done>OscillatorBank renders 22050 stereo frames per window. 11 layers mixed with 1/11 gain never clip. Panned sources produce asymmetric L/R output. EMA converges over multiple windows. All synth tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./synth/... -v` passes all tests (config, oscillator, layer, mixer, bank)
|
||||
- No test contains a TODO or Skip marker
|
||||
- `grep -r "GainPerLayer" synth/bank.go` confirms fixed-gain mixing
|
||||
- `grep "PanGains" synth/mixer.go synth/bank.go` confirms pan is used in both files
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- OscillatorBank creates 11 layers from ClassFreqConfigs and renders stereo PCM from WindowSnapshot
|
||||
- Constant-power panning produces correct L/R gains per D-12 positions
|
||||
- Fixed 1/11 gain per layer prevents clipping by construction (D-10)
|
||||
- Int16 byte conversion with clamping ready for go-lame encoder
|
||||
- All synth/ tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user