325 lines
12 KiB
Markdown
325 lines
12 KiB
Markdown
---
|
|
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>
|