12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-audio-synthesis-engine | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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
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:
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:
func AllClasses() []TrafficClass
type WindowSnapshot struct {
Counts map[TrafficClass]int64
TotalPackets int64
WindowIndex int
}
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).
cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1
<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>
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.
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).
cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1
<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>
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.
<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>