From 08f1a87a54af64e84896bf7f3557c49eebebbbf1 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 11:36:43 +0100 Subject: [PATCH] =?UTF-8?q?docs(02):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=203=20plans=20across=203=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 10 +- .../02-audio-synthesis-engine/02-01-PLAN.md | 372 ++++++++++++++++++ .../02-audio-synthesis-engine/02-02-PLAN.md | 324 +++++++++++++++ .../02-audio-synthesis-engine/02-03-PLAN.md | 299 ++++++++++++++ 4 files changed, 1002 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/02-audio-synthesis-engine/02-01-PLAN.md create mode 100644 .planning/phases/02-audio-synthesis-engine/02-02-PLAN.md create mode 100644 .planning/phases/02-audio-synthesis-engine/02-03-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ff7dd5c..8375ea8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -47,8 +47,12 @@ Plans: 3. Drone layer amplitude rises and falls with traffic volume over time — sustained traffic sounds louder, quiet periods fade 4. User can specify output path via `-o` flag; it defaults to `netsynth-.mp3` when omitted 5. An empty (zero-packet) input produces a clear error message instead of a corrupt or zero-byte MP3 -**Plans**: TBD -**UI hint**: no +**Plans:** 3 plans + +Plans: +- [ ] 02-01-PLAN.md — Environment setup (gcc, ffprobe, go-lame), synth config table, oscillator, EMA layer with tests +- [ ] 02-02-PLAN.md — Stereo mixer (constant-power panning), OscillatorBank multi-layer rendering with tests +- [ ] 02-03-PLAN.md — MP3 encoder package, zero-packet guard, -o CLI flag, ffprobe integration test ### Phase 3: Pipeline Integration and MVP **Goal**: Live capture flows end-to-end into audio synthesis — the complete v1 MVP: run, capture, Ctrl+C, get an MP3 @@ -78,6 +82,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Capture and Classification | 4/4 | Complete | 2026-03-25 | -| 2. Audio Synthesis Engine | 0/? | Not started | - | +| 2. Audio Synthesis Engine | 0/3 | Planning complete | - | | 3. Pipeline Integration and MVP | 0/? | Not started | - | | 4. Power User Features | 0/? | Not started | - | diff --git a/.planning/phases/02-audio-synthesis-engine/02-01-PLAN.md b/.planning/phases/02-audio-synthesis-engine/02-01-PLAN.md new file mode 100644 index 0000000..aeddffc --- /dev/null +++ b/.planning/phases/02-audio-synthesis-engine/02-01-PLAN.md @@ -0,0 +1,372 @@ +--- +phase: 02-audio-synthesis-engine +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - synth/config.go + - synth/oscillator.go + - synth/layer.go + - synth/config_test.go + - synth/layer_test.go + - go.mod + - go.sum +autonomous: true +requirements: [SYNTH-01, SYNTH-02] + +must_haves: + truths: + - "Each of 11 traffic classes has a unique frequency, harmonic profile, and pan position defined" + - "Phase-accumulator oscillator produces non-zero PCM samples with harmonics" + - "EMA amplitude smoothing moves current amplitude toward target over time" + - "Whisper floor prevents amplitude from reaching zero once a class has been seen" + - "go-lame v0.0.9 is in go.mod and CGo build succeeds" + artifacts: + - path: "synth/config.go" + provides: "FreqConfig, HarmonicDef types and ClassFreqConfigs table for all 11 classes" + contains: "ClassFreqConfigs" + - path: "synth/oscillator.go" + provides: "Phase-accumulator oscillator with Advance method" + contains: "func (o *Oscillator) Advance" + - path: "synth/layer.go" + provides: "Layer struct with EMA amplitude, whisper floor, target updates" + contains: "func (l *Layer) UpdateTarget" + key_links: + - from: "synth/config.go" + to: "classify/types.go" + via: "import classify.TrafficClass as map key" + pattern: "map\\[classify\\.TrafficClass\\]FreqConfig" + - from: "synth/layer.go" + to: "synth/oscillator.go" + via: "Layer embeds/uses Oscillator" + pattern: "Oscillator" +--- + + +Build the synthesis foundation: frequency/harmonic config table, phase-accumulator oscillator, and EMA amplitude layer — all with tests. Also install gcc, ffprobe, and go-lame dependency. + +Purpose: SYNTH-01 (distinct drone layers) and SYNTH-02 (amplitude dynamics) depend on these building blocks. Everything in this plan is tested in isolation before the mixer and encoder are built in Plan 02. + +Output: `synth/config.go`, `synth/oscillator.go`, `synth/layer.go` with corresponding test files. Environment ready for CGo builds. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-audio-synthesis-engine/02-CONTEXT.md +@.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md + +@classify/types.go + + + +From classify/types.go: +```go +type TrafficClass string + +const ( + ClassICMP TrafficClass = "ICMP" + ClassDNS TrafficClass = "DNS" + ClassHTTPS TrafficClass = "HTTPS" + ClassHTTP TrafficClass = "HTTP" + ClassSSH TrafficClass = "SSH" + ClassSMTP TrafficClass = "SMTP" + ClassNTP TrafficClass = "NTP" + ClassDHCP TrafficClass = "DHCP" + ClassOtherTCP TrafficClass = "other-TCP" + ClassOtherUDP TrafficClass = "other-UDP" + ClassUnknown TrafficClass = "unknown" +) + +func AllClasses() []TrafficClass { ... } + +type WindowSnapshot struct { + Counts map[TrafficClass]int64 + TotalPackets int64 + WindowIndex int +} +``` + + + + + + + Task 1: Environment setup and dependency installation + go.mod, go.sum + + - go.mod (current dependencies) + - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (environment notes) + + +1. Confirm Go is available. Check `which go` — if not found, check common paths: `/usr/local/go/bin/go`, `/home/dev/tools/go-install/go/bin/go`, or install via `sudo apt-get install -y golang-go`. Export PATH so `go` is accessible for subsequent commands. + +2. Install gcc (required for sjzar/go-lame CGo build): + ``` + sudo apt-get update && sudo apt-get install -y gcc + ``` + +3. Install ffprobe (required for MP3 validation in tests): + ``` + sudo apt-get install -y ffmpeg + ``` + +4. Add go-lame to go.mod: + ``` + go get github.com/sjzar/go-lame@v0.0.9 + ``` + +5. Run `go mod tidy` to clean up. + +6. Verify CGo build works by running `CGO_ENABLED=1 go build ./...` — should succeed with no errors. + +NOTE: Do NOT add go-audio/wav. Per research recommendation, skip the WAV intermediate and write PCM bytes directly to LameWriter. This eliminates a dependency and avoids the io.WriteSeeker complication. + + + go version && gcc --version && ffprobe -version && grep "go-lame" go.mod && CGO_ENABLED=1 go build ./... + + + - `go version` outputs a version string containing "go1.2" + - `gcc --version` outputs a version string + - `ffprobe -version` outputs a version string + - go.mod contains the line `github.com/sjzar/go-lame v0.0.9` + - `CGO_ENABLED=1 go build ./...` exits 0 + + Go, gcc, and ffprobe are available. go-lame v0.0.9 is in go.mod. CGo build succeeds. + + + + Task 2: Synth config, oscillator, and layer with tests + synth/config.go, synth/oscillator.go, synth/layer.go, synth/config_test.go, synth/layer_test.go + + - classify/types.go (TrafficClass constants, AllClasses, WindowSnapshot) + - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (patterns 1-2, frequency table, EMA formula) + - .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (decisions D-01 through D-09) + + + - TestAllClassesHaveConfig: Every class from classify.AllClasses() has an entry in ClassFreqConfigs + - TestFrequenciesInRange: All BaseHz values are within 60-800 Hz (per D-01) + - TestFrequenciesUnique: No two classes share the same BaseHz + - TestHarmonicsNonEmpty: Every class has at least 2 HarmonicDef entries (fundamental + at least 1 harmonic, per D-05) + - TestPanPositionsInRange: All Pan values in [-1.0, 1.0] + - TestOscillatorAdvance: Oscillator at 440 Hz / 44100 SR produces non-zero samples; 100 samples have both positive and negative values (sine wave) + - TestOscillatorPhaseWrap: After 44100 advances, phase stays in [0, 1) + - TestOscillatorDistinctFreqs: Oscillators at 65 Hz and 440 Hz produce different sample sequences + - TestEMAAmplitudeRise: Layer with target=1.0 and currentAmp=0.0 has currentAmp > 0.5 after 44100 samples (1 second at tau=1.0) + - TestEMAAmplitudeDecay: Layer with target=0.03 (whisper floor) and currentAmp=1.0 has currentAmp < 0.5 after 44100 samples + - TestWhisperFloor: Layer marked as seen=true with target set from zero-count snapshot has targetAmp >= whisperFloor (0.03) + - TestWhisperFloorNotSeenIsZero: Layer with seen=false has targetAmp == 0.0 + + +**synth/config.go** — Create the frequency/harmonic/pan configuration table. Per D-01 through D-06 and D-11/D-12: + +```go +package synth + +import "github.com/netsynth/netsynth/classify" + +const ( + SampleRate = 44100 // D-13: CD quality + WindowMs = 500 // matches aggregate.DefaultWindowMs + SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050 + NumLayers = 11 + GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909 + WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude +) + +type HarmonicDef struct { + Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc. + Amplitude float64 // relative weight +} + +type FreqConfig struct { + BaseHz float64 + Harmonics []HarmonicDef + Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right +} + +// ClassFreqConfigs maps each traffic class to its synthesis parameters. +// Frequencies use musical intervals per D-02/D-03. Harmonics per D-05/D-06. +// Pan positions per D-12. +var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ + classify.ClassICMP: {65.0, []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, 0.0}, + classify.ClassDNS: {110.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, -0.2}, + classify.ClassHTTPS: {175.0, []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, 0.2}, + classify.ClassHTTP: {220.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, -0.35}, + classify.ClassSSH: {330.0, []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, 0.35}, + classify.ClassSMTP: {440.0, []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, -0.55}, + classify.ClassNTP: {520.0, []HarmonicDef{{1, 1.0}, {2, 0.25}}, 0.55}, + classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75}, + classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75}, + classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75}, + classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0}, + // D-04: 437 Hz is ~12 cents flat from A4 (440 Hz/SMTP). + // Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal. +} +``` + +**synth/oscillator.go** — Phase-accumulator oscillator (Pattern 1 from research): + +```go +package synth + +import "math" + +type Oscillator struct { + phase float64 + freq float64 + sr float64 +} + +func NewOscillator(freq float64, sampleRate int) *Oscillator { + return &Oscillator{freq: freq, sr: float64(sampleRate)} +} + +// Advance returns one sample: fundamental + harmonics summed and normalized to [-1, 1]. +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 +} + +// Phase returns the current phase (for testing). +func (o *Oscillator) Phase() float64 { + return o.phase +} +``` + +**synth/layer.go** — Layer with EMA amplitude smoothing (Pattern 2): + +```go +package synth + +import "math" + +// EMAAlpha computes the per-sample smoothing coefficient for a given time constant. +// tau=1.0 at SR=44100 gives alpha ~0.0000227 (63% of target reached in 1 second). Per D-07. +func EMAAlpha(tau float64, sampleRate int) float64 { + return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate))) +} + +type Layer struct { + Config FreqConfig + Osc *Oscillator + currentAmp float64 + targetAmp float64 + alpha float64 // EMA coefficient + seen bool // whether this class has ever had count > 0 + whisper float64 // whisper floor amplitude (D-08) +} + +func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer { + return &Layer{ + Config: cfg, + Osc: NewOscillator(cfg.BaseHz, sampleRate), + alpha: EMAAlpha(tau, sampleRate), + whisper: WhisperFloor, + } +} + +// UpdateTarget sets the target amplitude from a packet count and max count across all classes. +// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate. +func (l *Layer) UpdateTarget(count int64, maxCount int64) { + if count > 0 { + l.seen = true + } + if !l.seen { + l.targetAmp = 0.0 + return + } + normalizedRate := 0.0 + if maxCount > 0 { + normalizedRate = float64(count) / float64(maxCount) + } + l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate +} + +// AdvanceSample renders one sample and advances the EMA amplitude toward target. +// Returns the raw mono sample (before pan/gain). +func (l *Layer) AdvanceSample() float64 { + sample := l.Osc.Advance(l.Config.Harmonics) + l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp) + return sample * l.currentAmp +} + +// CurrentAmp returns the current amplitude (for testing). +func (l *Layer) CurrentAmp() float64 { + return l.currentAmp +} + +// TargetAmp returns the target amplitude (for testing). +func (l *Layer) TargetAmp() float64 { + return l.targetAmp +} + +// Seen returns whether this layer has ever received traffic (for testing). +func (l *Layer) Seen() bool { + return l.seen +} +``` + +Write tests FIRST (RED), then create the implementation files (GREEN). Run `go test ./synth/...` after each. + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... -v -count=1 + + + - synth/config.go contains `var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{` + - synth/config.go contains `WhisperFloor = 0.03` + - synth/config.go contains `SampleRate = 44100` + - synth/config.go contains `GainPerLayer` + - synth/oscillator.go contains `func (o *Oscillator) Advance(harmonics []HarmonicDef) float64` + - synth/oscillator.go contains `o.phase -= 1.0` (NOT math.Mod) + - synth/layer.go contains `func (l *Layer) UpdateTarget(count int64, maxCount int64)` + - synth/layer.go contains `func (l *Layer) AdvanceSample() float64` + - synth/layer.go contains `l.whisper + (1.0-l.whisper)*normalizedRate` + - synth/config_test.go contains `TestAllClassesHaveConfig` + - synth/config_test.go contains `TestFrequenciesInRange` + - synth/layer_test.go contains `TestEMAAmplitudeRise` + - synth/layer_test.go contains `TestWhisperFloor` + - `go test ./synth/...` exits 0 + + All 11 traffic classes have config entries with unique frequencies in 60-800 Hz. Oscillator produces correct waveform samples. EMA amplitude smoothing converges toward target with whisper floor enforcement. All tests pass. + + + + + +- `go test ./synth/... -v` passes all tests +- `CGO_ENABLED=1 go build ./...` succeeds (gcc + go-lame working) +- `grep -c "ClassFreqConfigs" synth/config.go` returns at least 1 +- Every class from `classify.AllClasses()` has config entry (verified by TestAllClassesHaveConfig) + + + +- synth/config.go defines FreqConfig for all 11 TrafficClass values with frequencies in 60-800 Hz range +- synth/oscillator.go implements phase-accumulator with harmonic rendering +- synth/layer.go implements EMA amplitude smoothing with whisper floor +- All tests in synth/ pass +- gcc, ffprobe, and go-lame are installed and working + + + +After completion, create `.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-audio-synthesis-engine/02-02-PLAN.md b/.planning/phases/02-audio-synthesis-engine/02-02-PLAN.md new file mode 100644 index 0000000..3b67d3c --- /dev/null +++ b/.planning/phases/02-audio-synthesis-engine/02-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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: +```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 +} +``` + + + + + + + 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: + +```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). + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1 + + + - 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 + + 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: + +```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). + + + cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1 + + + - 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) + + 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 + + + +- 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 + + + +After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-audio-synthesis-engine/02-03-PLAN.md b/.planning/phases/02-audio-synthesis-engine/02-03-PLAN.md new file mode 100644 index 0000000..890b049 --- /dev/null +++ b/.planning/phases/02-audio-synthesis-engine/02-03-PLAN.md @@ -0,0 +1,299 @@ +--- +phase: 02-audio-synthesis-engine +plan: 03 +type: execute +wave: 3 +depends_on: ["02-02"] +files_modified: + - encode/mp3.go + - encode/mp3_test.go + - cmd/netsynth/main.go +autonomous: true +requirements: [OUT-01, OUT-02, OUT-03] + +must_haves: + truths: + - "Synthetic WindowSnapshots produce a valid MP3 file that passes ffprobe validation" + - "Zero-packet input produces a clear error message and no file on disk" + - "User can specify -o flag for output path; default includes timestamp" + - "MP3 is encoded at 44100 Hz, 128 kbps, stereo via go-lame" + artifacts: + - path: "encode/mp3.go" + provides: "EncodeMP3 function and RunSynthesis orchestrator" + contains: "func EncodeMP3" + - path: "encode/mp3_test.go" + provides: "Integration test with ffprobe validation and zero-packet guard test" + contains: "TestMP3Valid" + - path: "cmd/netsynth/main.go" + provides: "-o flag wiring with timestamp default" + contains: "outputPath" + key_links: + - from: "encode/mp3.go" + to: "synth/bank.go" + via: "RunSynthesis calls bank.RenderWindow for each snapshot" + pattern: "bank\\.RenderWindow" + - from: "encode/mp3.go" + to: "synth/mixer.go" + via: "Uses StereoFramesToInt16Bytes for PCM conversion" + pattern: "StereoFramesToInt16Bytes" + - from: "cmd/netsynth/main.go" + to: "encode/mp3.go" + via: "Will wire RunSynthesis in Phase 3; -o flag defined now" + pattern: "outputPath" +--- + + +Build the MP3 encoder package and wire the -o output flag into the CLI. This completes OUT-01 (output path flag), OUT-02 (valid MP3 encoding), and OUT-03 (zero-packet guard). An integration test synthesizes from synthetic snapshots and validates the MP3 with ffprobe. + +Purpose: The encoder is the final stage of the audio pipeline. After this plan, the complete synthesis chain (WindowSnapshot -> OscillatorBank -> stereo PCM -> MP3 file) is validated end-to-end against synthetic data. + +Output: `encode/mp3.go`, `encode/mp3_test.go`, updated `cmd/netsynth/main.go` with -o flag. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 +@.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md + +@classify/types.go +@cmd/netsynth/main.go + + + +From synth/bank.go: +```go +func NewBank(tau float64) *OscillatorBank +func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 +``` + +From synth/mixer.go: +```go +func StereoFramesToInt16Bytes(frames [][2]float64) []byte +``` + +From synth/config.go: +```go +const SampleRate = 44100 +const SamplesPerWindow = 22050 +``` + +From classify/types.go: +```go +type WindowSnapshot struct { + Counts map[TrafficClass]int64 + TotalPackets int64 + WindowIndex int +} +``` + + +From github.com/sjzar/go-lame: +```go +func NewWriter(w io.Writer) *LameWriter +wr.Encoder.SetInSamplerate(int) +wr.Encoder.SetOutSamplerate(int) +wr.Encoder.SetNumChannels(int) +wr.Encoder.SetBitrate(int) +wr.Encoder.SetQuality(int) +wr.Encoder.InitParams() // MUST call after all Set* and before Write +wr.Write([]byte) (int, error) +wr.Close() error +``` + + + + + + + Task 1: MP3 encoder package with zero-packet guard and integration test + encode/mp3.go, encode/mp3_test.go + + - synth/bank.go (NewBank, RenderWindow signatures) + - synth/mixer.go (StereoFramesToInt16Bytes) + - synth/config.go (SampleRate constant) + - classify/types.go (WindowSnapshot, TrafficClass) + - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 4: go-lame API, zero-packet guard, anti-patterns) + - .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-14: 128 kbps, D-16: zero-packet error) + + + - TestMP3Valid: Create 3 synthetic WindowSnapshots with varied traffic (ICMP=50, DNS=30 in snap1; HTTPS=200 in snap2; SSH=10, HTTP=80 in snap3). Run RunSynthesis into a temp file. Assert: file exists, file size > 0, `ffprobe -v error -show_entries format=format_name,duration,nb_streams -of csv=p=0 ` outputs "mp3" in format_name, duration > 0, nb_streams=1. + - TestZeroPacketError: Pass empty slice of snapshots (or snapshots where all TotalPackets=0). Assert: returns non-nil error containing "no packets", output file does NOT exist on disk. + - TestEncodeMP3DirectBytes: Call EncodeMP3 with known [][2]float64 frames (e.g., 44100 frames of 440Hz sine). Assert: output file > 0 bytes, ffprobe validates it as MP3. + + +**encode/mp3.go** — MP3 encoding and synthesis orchestrator: + +```go +package encode + +import ( + "fmt" + "os" + + lame "github.com/sjzar/go-lame" + + "github.com/netsynth/netsynth/classify" + "github.com/netsynth/netsynth/synth" +) + +// EncodeMP3 writes stereo PCM frames to an MP3 file at the given path. +// sampleRate=44100, bitrate=128 kbps, stereo, quality=5. Per D-13, D-14. +// CRITICAL: InitParams() must be called after all Set* calls (Pitfall 1). +func EncodeMP3(outputPath string, frames [][2]float64, sampleRate int) error { + f, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create output file: %w", err) + } + defer f.Close() + + wr := lame.NewWriter(f) + wr.Encoder.SetInSamplerate(sampleRate) + wr.Encoder.SetOutSamplerate(sampleRate) + wr.Encoder.SetNumChannels(2) + wr.Encoder.SetBitrate(128) + wr.Encoder.SetQuality(5) + wr.Encoder.InitParams() // MUST be after all Set* calls + + pcmBytes := synth.StereoFramesToInt16Bytes(frames) + if _, err := wr.Write(pcmBytes); err != nil { + return fmt.Errorf("write PCM to LAME encoder: %w", err) + } + if err := wr.Close(); err != nil { + return fmt.Errorf("finalize MP3: %w", err) + } + return nil +} + +// RunSynthesis consumes a slice of WindowSnapshots, renders audio via OscillatorBank, +// and encodes to MP3 at outputPath. Returns error if zero packets were captured (D-16/OUT-03). +// NOTE: Accepts a slice, not a channel — caller collects snapshots before calling this. +// The zero-packet check MUST happen before file creation (Pitfall 5). +func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error { + // D-16 / OUT-03: Zero-packet guard — check BEFORE creating output file + var totalPackets int64 + for _, snap := range snapshots { + totalPackets += snap.TotalPackets + } + if totalPackets == 0 { + return fmt.Errorf("no packets captured: output MP3 not written (empty capture produces no audio)") + } + + // Render all windows to stereo frames + bank := synth.NewBank(1.0) // tau=1.0s per D-07 + var allFrames [][2]float64 + for _, snap := range snapshots { + frames := bank.RenderWindow(snap) + allFrames = append(allFrames, frames...) + } + + // Encode to MP3 + return EncodeMP3(outputPath, allFrames, synth.SampleRate) +} +``` + +Key design: +- `RunSynthesis` takes a `[]classify.WindowSnapshot` slice (not a channel). The caller (main.go in Phase 3) collects from the channel, then passes the slice. This enables the zero-packet check before file creation. +- `EncodeMP3` is a separate function so it can be tested independently with raw frames. +- PCM bytes are written in a single `Write` call (batch output, not streaming — per project constraints). + +Write tests FIRST, using `os.CreateTemp` for output files and `exec.Command("ffprobe", ...)` for validation. Clean up temp files in test teardown. + + + cd /home/dev/workspace/yoloyolo && CGO_ENABLED=1 go test ./encode/... -v -count=1 + + + - encode/mp3.go contains `func EncodeMP3(outputPath string, frames [][2]float64, sampleRate int) error` + - encode/mp3.go contains `func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error` + - encode/mp3.go contains `wr.Encoder.InitParams()` AFTER all `Set*` calls + - encode/mp3.go contains `if totalPackets == 0` guard BEFORE `os.Create` + - encode/mp3.go contains the error string "no packets captured" + - encode/mp3.go imports `github.com/sjzar/go-lame` + - encode/mp3_test.go contains `TestMP3Valid` + - encode/mp3_test.go contains `TestZeroPacketError` + - encode/mp3_test.go uses `ffprobe` to validate MP3 output + - `CGO_ENABLED=1 go test ./encode/...` exits 0 + + EncodeMP3 produces a valid MP3 file validated by ffprobe. RunSynthesis renders synthetic snapshots through the full pipeline. Zero-packet input returns error without creating a file. All encode tests pass. + + + + Task 2: Wire -o output flag into CLI + cmd/netsynth/main.go + + - cmd/netsynth/main.go (current Cobra flag setup, run function) + - .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-15: -o flag, timestamp default) + - .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (output flag wiring example) + + +Add the `-o` / `--output` flag to main.go. Per D-15: defaults to `netsynth-.mp3` when omitted. The flag is defined now but NOT wired to the synthesis pipeline yet — that happens in Phase 3 when capture and synthesis are integrated. + +1. Add a package-level var: + ```go + var outputPath string + ``` + +2. Add the flag registration in `main()` after the existing flags: + ```go + rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-.mp3)") + ``` + +3. Add the timestamp default resolution at the top of `run()`, after the `--list-interfaces` check and before the interface check: + ```go + // Resolve default output path (D-15 / OUT-01) + if outputPath == "" { + outputPath = fmt.Sprintf("netsynth-%s.mp3", time.Now().Format("20060102-150405")) + } + ``` + +4. Add `"time"` to the imports. + +5. Add a comment in the snapshot consumption loop marking where Phase 3 will wire synthesis: + ```go + // TODO(phase-3): Pass snapshots to encode.RunSynthesis(snapshots, outputPath) + ``` + +Do NOT import the encode package yet — Phase 3 handles that. The flag must be functional (parseable, shows in --help) even though the synthesis pipeline isn't wired. + + + cd /home/dev/workspace/yoloyolo && go build -o /dev/null ./cmd/netsynth/ && go run ./cmd/netsynth/ --help 2>&1 | grep -q "\-o.*output" && echo "OK" + + + - cmd/netsynth/main.go contains `var outputPath string` + - cmd/netsynth/main.go contains `StringVarP(&outputPath, "output", "o",` + - cmd/netsynth/main.go contains `netsynth-%s.mp3` + - cmd/netsynth/main.go contains `time.Now().Format("20060102-150405")` + - cmd/netsynth/main.go imports `"time"` + - `go build ./cmd/netsynth/` exits 0 + - `go run ./cmd/netsynth/ --help` output contains `-o, --output` + + The -o/--output flag is registered in Cobra, defaults to netsynth-.mp3, visible in --help. Build succeeds. Phase 3 will wire it to RunSynthesis. + + + + + +- `CGO_ENABLED=1 go test ./...` passes all tests (synth + encode + existing) +- `ffprobe` validates the test-generated MP3 (within encode test) +- `go run ./cmd/netsynth/ --help` shows `-o, --output` flag +- `grep "no packets captured" encode/mp3.go` confirms zero-packet error message + + + +- encode/mp3.go produces valid MP3 files from synthetic WindowSnapshots (validated by ffprobe) +- Zero-packet guard prevents file creation and returns descriptive error (OUT-03) +- -o flag registered in CLI with timestamp default (OUT-01) +- Full test suite passes: `CGO_ENABLED=1 go test ./...` +- Complete Phase 2 audio pipeline validated end-to-end against synthetic data + + + +After completion, create `.planning/phases/02-audio-synthesis-engine/02-03-SUMMARY.md` +