Files
yoloyolo/.planning/phases/02-audio-synthesis-engine/02-02-PLAN.md
T

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
02-01
synth/bank.go
synth/mixer.go
synth/bank_test.go
synth/mixer_test.go
true
SYNTH-03
truths artifacts key_links
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
path provides contains
synth/bank.go OscillatorBank with NewBank, RenderWindow methods func (b *OscillatorBank) RenderWindow
path provides contains
synth/mixer.go panGains constant-power function and float64-to-int16 conversion func PanGains
from to via pattern
synth/bank.go synth/layer.go Bank holds map[TrafficClass]*Layer map[classify.TrafficClass]*Layer
from to via pattern
synth/bank.go synth/config.go Reads ClassFreqConfigs to initialize layers ClassFreqConfigs
from to via pattern
synth/mixer.go synth/bank.go StereoFramesToInt16Bytes converts bank output to encoder-ready bytes StereoFramesToInt16Bytes
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.

<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
}
Task 1: Stereo mixer utilities synth/mixer.go, synth/mixer_test.go - 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) - 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) **synth/mixer.go** — Constant-power pan law (per D-11) and PCM conversion utilities:
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.

Task 2: OscillatorBank — multi-layer rendering from WindowSnapshot synth/bank.go, synth/bank_test.go - 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) - 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) **synth/bank.go** — The core synthesis engine. Per D-10, each layer gets GainPerLayer (1/11) of headroom:
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.

- `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

<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>
After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md`