diff --git a/.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md b/.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md new file mode 100644 index 0000000..4c80c9f --- /dev/null +++ b/.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md @@ -0,0 +1,679 @@ +# Phase 2: Audio Synthesis Engine - Research + +**Researched:** 2026-03-26 +**Domain:** Go additive audio synthesis, stereo MP3 encoding, EMA amplitude smoothing, WAV intermediate format +**Confidence:** HIGH — decisions are locked, stack is pre-determined in CLAUDE.md, all API signatures verified against official pkg.go.dev docs. + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Tone mapping** +- D-01: Wide ambient frequency range: 60-800 Hz across 11 traffic classes +- D-02: Frequencies use musical intervals (harmonic relationships — fifths, octaves, etc.) so layers blend pleasantly +- D-03: Approximate register assignments: ICMP ~65 Hz (deep bass), DNS ~110 Hz, HTTPS ~175 Hz, HTTP ~220 Hz, SSH ~330 Hz, SMTP ~440 Hz, NTP ~520 Hz, DHCP ~600 Hz, other-TCP/other-UDP ~700-800 Hz +- D-04: "unknown" traffic gets a dissonant/detuned tone — slightly off-key or beating frequency + +**Waveform texture** +- D-05: Each drone layer uses fundamental + 2-3 harmonics (not pure sine waves) +- D-06: Harmonic ratios vary per traffic class for subtle timbral distinction + +**Amplitude dynamics** +- D-07: EMA smoothing with medium responsiveness (1-2 second attack/decay) +- D-08: Layers never fade to full silence — once seen, stays at a fixed whisper floor (~2-5% of max amplitude) +- D-09: Fixed whisper floor (not recency-based decay) + +**Mixing and output** +- D-10: Fixed equal gain per layer — each of 11 layers gets 1/11 of headroom (~0.09 max amplitude) +- D-11: Stereo output with register-based panning: bass center, mid spread L/R, high frequencies wider +- D-12: Panning positions: ICMP center, DNS slight-L, HTTPS slight-R, HTTP center-L, SSH center-R, SMTP mid-L, NTP mid-R, DHCP wide-L, other-TCP wide-R, other-UDP wide-L, unknown center +- D-13: 44100 Hz sample rate (CD quality) +- D-14: 128 kbps MP3 encoding via `sjzar/go-lame` +- D-15: `-o` flag for output path; defaults to `netsynth-.mp3` when omitted +- D-16: Empty (zero-packet) input produces a clear error message, not a corrupt or zero-byte MP3 + +### Claude's Discretion +- Exact Hz values per class (within the 60-800 Hz range, using musical intervals) +- Specific harmonic ratios per traffic class (within the "2-3 harmonics" constraint) +- Exact EMA alpha/decay constants to achieve the 1-2 second feel +- Exact whisper floor percentage (within 2-5% range) +- WAV intermediate format usage (per CLAUDE.md recommendation of go-audio/wav) +- Stereo panning implementation (constant-power pan law vs linear) + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| SYNTH-01 | Each traffic class generates a distinct ambient/drone layer (layered sine/harmonic waves) | Additive synthesis pattern: oscillator struct with phase accumulator, harmonic table, per-class config | +| SYNTH-02 | Drone layer amplitudes evolve over time windows based on traffic volume per class | EMA update pattern: α derived from window duration; amplitude target driven by WindowSnapshot.Counts | +| SYNTH-03 | Multiple drone layers are mixed into a single coherent audio stream without distortion | Fixed-gain mixer: 1/11 per layer guarantees sum ≤ 1.0; constant-power stereo panning; go-audio/wav → go-lame pipeline | +| OUT-01 | User can specify output file path via `-o` flag (defaults to `netsynth-.mp3`) | Cobra `StringVarP` flag; `time.Now().Format` for timestamp default | +| OUT-02 | Output is encoded as a valid MP3 file | `sjzar/go-lame` LameWriter: set params → InitParams() → Write([]byte PCM) → Close() | +| OUT-03 | Empty captures (zero packets) produce a clear error instead of a corrupt file | Check `totalPackets == 0` before encoding; return descriptive `fmt.Errorf` instead | + + +--- + +## Summary + +Phase 2 builds the complete audio pipeline: oscillator bank → EMA amplitude smoother → stereo mixer → WAV buffer → LAME MP3 encoder. It operates against synthetic `classify.WindowSnapshot` inputs — no live capture is involved. All major library choices are locked in CLAUDE.md and prior research: `sjzar/go-lame` v0.0.9 for MP3, `go-audio/wav` for the intermediate format, hand-rolled additive synthesis, and Cobra for the `-o` flag. + +The synthesis architecture is well-understood from Phase 1 research. The core math is: for each window, compute a target amplitude per traffic class from packet counts, advance the EMA toward that target, render N PCM samples (fundamental + 2-3 harmonics with class-specific ratios), apply constant-power stereo panning into L/R channels, mix the 11 layers, write stereo interleaved int16 samples to a WAV buffer, and encode to MP3 via go-lame at 44100 Hz / 128 kbps / 2 channels. + +Two environmental blockers need Wave 0 tasks: `gcc` and `ffprobe` are not installed. `gcc` is required to build `sjzar/go-lame` (CGo). `ffprobe` is needed for the success criterion that validates the MP3 output. Both are available via `apt` on this machine. + +**Primary recommendation:** Build `synth/`, `encode/`, and extend `cmd/` as three independent Go packages. Test each in isolation using table-driven tests with synthetic `WindowSnapshot` inputs and PCM buffer assertions before wiring to the full pipeline in Phase 3. + +--- + +## Standard Stack + +### Core (locked by CLAUDE.md) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `github.com/sjzar/go-lame` | v0.0.9 | MP3 encoding via embedded LAME C source | Locked. Embeds libmp3lame; no system dep. CGO_ENABLED=1 required. | +| `github.com/go-audio/wav` | latest | WAV file I/O as PCM intermediate buffer | Locked. Simplifies PCM → LAME pipeline; decouples synthesis from encoding. | +| `github.com/spf13/cobra` | v1.10.2 | `-o` flag; already in go.mod | Already wired in main.go. | +| Hand-rolled additive synthesis | — | Oscillator, amplitude EMA, mixer | Locked. `go-audio/generator` archived 2026-02-01; no library adds value. | + +**go-lame is NOT yet in go.mod.** Wave 0 must add it. + +**go-audio/wav is NOT yet in go.mod.** Wave 0 must add it. + +**Installation:** +```bash +# Requires PATH to Go binary (see environment notes) +go get github.com/sjzar/go-lame@v0.0.9 +go get github.com/go-audio/wav@latest +``` + +--- + +## Architecture Patterns + +### Recommended Package Structure + +``` +synth/ +├── config.go # FreqConfig struct: frequency table, harmonic ratios, pan positions +├── oscillator.go # Phase-accumulator sine oscillator, Harmonics() render method +├── layer.go # Layer struct: Oscillator + EMA amplitude state per TrafficClass +├── bank.go # OscillatorBank: 11 layers, Update(WindowSnapshot), Render(N frames) → [][2]float64 +└── mixer.go # Sum layers L+R, convert float64 → int16 interleaved (stereo) +encode/ +└── mp3.go # NewMP3Encoder(path, sampleRate, bitrate) → writes WAV buffer → LAME encode → file +cmd/netsynth/ +└── main.go # Add -o flag, zero-packet guard, wire synth bank → encode +``` + +### Pattern 1: Phase-Accumulator Oscillator with Harmonics + +**What:** Each oscillator maintains a `phase float64` that advances by `freq/sampleRate` per sample, wrapping at 1.0. `math.Sin(2π * phase)` yields the fundamental. Harmonics at integer multiples use the same phase scaled by harmonic ratio. + +**Why:** Avoids `math.Sin(2π * freq * t / sampleRate)` which loses floating-point precision over long runs. Phase accumulator stays near zero, preserving accuracy indefinitely. + +**Example:** +```go +// Source: standard DSP practice; verified against Dylan Meeus audio-from-scratch series +type Oscillator struct { + phase float64 + freq float64 + sr float64 +} + +// Advance returns one sample (fundamental + harmonics summed and normalized). +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 // normalize to [-1, 1] + } + return 0 +} +``` + +### Pattern 2: EMA Amplitude Smoothing + +**What:** On each `WindowSnapshot`, compute a `targetAmplitude` for each layer. The current amplitude moves toward target each audio sample using coefficient `α`. α is derived from desired time constant τ (in seconds) and sample rate SR: `α = 1 - exp(-1 / (τ * SR))`. + +**Why 1-2 second feel:** With τ = 1.0s and SR = 44100, α ≈ 0.0000227. After τ seconds of samples, amplitude reaches ~63% of target — perceptually "medium responsive." For attack/decay symmetry, use the same α for both directions. + +**Formula:** +```go +// Source: standard signal processing; EMA with continuous-time derivation +tau := 1.0 // seconds — Claude's discretion; adjust for 1-2s feel +alpha := 1.0 - math.Exp(-1.0/(tau*float64(sampleRate))) + +// Per-sample update (inside render loop): +layer.currentAmp += alpha * (layer.targetAmp - layer.currentAmp) +``` + +**Whisper floor:** After receiving the first snapshot showing count > 0 for a class, set `layer.whisperFloor = 0.03` (3% — within the 2-5% range). The target amplitude is: +```go +target := whisperFloor + (1.0 - whisperFloor) * normalizedRate +// where normalizedRate = float64(count) / float64(maxCountSeen) +``` + +### Pattern 3: Constant-Power Stereo Panning + +**What:** Given a pan position `p ∈ [-1, 1]` where -1 = full left, 0 = center, +1 = full right, the per-channel gains are: +```go +// Source: standard audio engineering — constant-power (equal-power) pan law +angle := (p + 1.0) / 2.0 * math.Pi / 2.0 // map [-1,1] → [0, π/2] +gainL := math.Cos(angle) +gainR := math.Sin(angle) +``` +Constant-power panning preserves perceived loudness as the signal moves across the stereo field. Linear panning creates a loudness dip at center. Given the ambient nature of this output, constant-power is correct. + +**Pan table (from D-12):** +```go +var PanPositions = map[classify.TrafficClass]float64{ + classify.ClassICMP: 0.0, // center + classify.ClassDNS: -0.2, // slight-L + classify.ClassHTTPS: +0.2, // slight-R + classify.ClassHTTP: -0.35, // center-L + classify.ClassSSH: +0.35, // center-R + classify.ClassSMTP: -0.55, // mid-L + classify.ClassNTP: +0.55, // mid-R + classify.ClassDHCP: -0.75, // wide-L + classify.ClassOtherTCP: +0.75, // wide-R + classify.ClassOtherUDP: -0.75, // wide-L (matches DHCP register) + classify.ClassUnknown: 0.0, // center (stand out via dissonance, not position) +} +``` + +### Pattern 4: WAV Buffer → LAME Pipeline + +**What:** Render all PCM samples to an `audio.IntBuffer` (via `go-audio/wav`), write to a `bytes.Buffer` as WAV, then pass the raw PCM bytes to `sjzar/go-lame`'s `LameWriter.Write()`. + +**Why WAV intermediate:** Decouples synthesis timing from LAME's CGo overhead. The entire PCM block is available before encoding starts, which is correct for a batch-output tool. WAV also serves as a debug artifact — save it alongside the MP3 during development. + +**go-lame API (verified against pkg.go.dev):** +```go +// Source: pkg.go.dev/github.com/sjzar/go-lame — verified 2026-03-26 +import "github.com/sjzar/go-lame" + +outFile, _ := os.Create(outputPath) +defer outFile.Close() + +wr := lame.NewWriter(outFile) +wr.Encoder.SetInSamplerate(44100) +wr.Encoder.SetOutSamplerate(44100) +wr.Encoder.SetNumChannels(2) // stereo +wr.Encoder.SetBitrate(128) +wr.Encoder.SetQuality(5) +wr.Encoder.InitParams() // MUST call after all Set* calls + +// PCM input format: int16 little-endian interleaved stereo bytes +// Render: []int16{L0, R0, L1, R1, ...} → convert to []byte → Write +pcmBytes := int16SliceToBytes(interleavedSamples) +wr.Write(pcmBytes) +wr.Close() // flushes LAME internal buffer, writes final MP3 frames +``` + +**CRITICAL:** `InitParams()` must be called after all configuration `Set*` calls and before the first `Write`. Skipping it produces corrupted MP3 output. + +**int16 conversion:** +```go +import "encoding/binary" + +func int16SliceToBytes(samples []int16) []byte { + buf := make([]byte, len(samples)*2) + for i, s := range samples { + binary.LittleEndian.PutUint16(buf[i*2:], uint16(s)) + } + return buf +} +``` + +### Pattern 5: Harmonic Config Table + +**What:** A static table maps each `TrafficClass` to its `FreqConfig`. Defined in `synth/config.go`. No magic numbers elsewhere. + +**Recommended frequency assignments (Claude's discretion, within D-01 to D-04 constraints):** + +The user specified approximate Hz values. Musical intervals starting from 65 Hz (ICMP as the bass root): +- 65 Hz is close to C2 (musical bass). Intervals: fifth = ×1.5, octave = ×2, major third ≈ ×1.25, minor third ≈ ×1.2. + +```go +// Source: derived from D-03 constraints using musical interval ratios +type HarmonicDef struct { + Ratio int // harmonic number (1 = fundamental, 2 = octave, 3 = fifth+octave) + Amplitude float64 // relative weight +} + +type FreqConfig struct { + BaseHz float64 + Harmonics []HarmonicDef + Pan float64 // from PanPositions table +} + +var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ + // Deep bass — pure, minimal harmonics (pad feel) + classify.ClassICMP: {65.0, []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, 0.0}, + // Sub-bass — slightly brighter + classify.ClassDNS: {110.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, -0.2}, + // Warm mid-bass — HTTPS is the most common traffic, warm pad + classify.ClassHTTPS: {175.0, []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, 0.2}, + // Mid — HTTP slightly brighter than HTTPS + classify.ClassHTTP: {220.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, -0.35}, + // SSH — buzzy (odd harmonics emphasized for character) + classify.ClassSSH: {330.0, []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, 0.35}, + // SMTP — clean tone + classify.ClassSMTP: {440.0, []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, -0.55}, + // NTP — high, pure + classify.ClassNTP: {520.0, []HarmonicDef{{1, 1.0}, {2, 0.25}}, 0.55}, + // DHCP — wide stereo, brighter + classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75}, + // other-TCP — high, minimal harmonics + classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75}, + // other-UDP + classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75}, + // unknown — detuned (beating frequency: fundamental + 1.03× = ~3Hz beat at 440Hz) + classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0}, + // The 437 Hz combined with its 2nd harmonic at 874 Hz creates beating against + // any SMTP (440 Hz) present in the mix, reinforcing "something doesn't belong" +} +``` + +Note on "unknown" detuning: 437 Hz is ~12 cents flat from A4 (440 Hz). If SMTP traffic is present, the 3 Hz beat between 437 and 440 is perceptually jarring. The 2nd harmonic at 874 Hz also beats against the SMTP fundamental's second harmonic at 880 Hz — a compound dissonance. This is the correct implementation of D-04. + +### Pattern 6: Per-Window Render Loop + +**What:** For each received `WindowSnapshot`, update amplitude targets, then render exactly `samplesPerWindow` PCM frames (= `sampleRate * windowDurationMs / 1000`). + +```go +// samplesPerWindow = 44100 * 500 / 1000 = 22050 samples per 500ms window +// Each frame = 2 int16 values (L, R) for stereo +samplesPerWindow := sampleRate * windowMs / 1000 + +func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 { + b.updateTargets(snap) // sets targetAmp per layer from snap.Counts + frames := make([][2]float64, samplesPerWindow) + for i := range frames { + var sumL, sumR float64 + for _, layer := range b.layers { + sample := layer.osc.Advance(layer.config.Harmonics) + layer.currentAmp += layer.alpha * (layer.targetAmp - layer.currentAmp) + gainL, gainR := panGains(layer.config.Pan) + sumL += sample * layer.currentAmp * layer.gainPerLayer * gainL + sumR += sample * layer.currentAmp * layer.gainPerLayer * gainR + } + frames[i] = [2]float64{sumL, sumR} + } + return frames +} +``` + +**gainPerLayer = 1.0 / 11 ≈ 0.0909** (D-10). With 11 layers each at max amplitude 1.0 and gain 1/11, the theoretical maximum sum is exactly 1.0. Clipping is impossible by construction — no dynamic limiter needed. + +### Anti-Patterns to Avoid + +- **Calling InitParams() before all Set* calls:** go-lame silently uses defaults for any parameters set after InitParams(). Always configure fully, then call InitParams() once. +- **Using float32 PCM samples directly with go-lame:** go-lame's `Encode` and `Write` expect `[]byte` representing int16 little-endian PCM. Convert float64 samples to int16 with `int16(sample * 32767)` and then to bytes via `encoding/binary`. +- **Writing an empty MP3:** If zero snapshots are received (D-16 / OUT-03), detect this before opening the output file. Opening the file before checking creates a zero-byte file on disk even if encoding fails. +- **Streaming MP3 mid-run:** MP3 VBR headers require finalization. Buffer all PCM then encode at the end. This is the correct pattern for non-interactive output (see REQUIREMENTS.md Out of Scope). +- **Phase accumulator overflow:** Use `phase -= 1.0` (not `math.Mod`) when phase exceeds 1.0. `math.Mod` is slower and introduces floating-point artifacts. +- **go-audio/wav for the final output file:** go-audio/wav requires an `io.WriteSeeker` (must support `Seek`). A `bytes.Buffer` does not implement `Seek`. Use `os.File` or `bytes.Buffer` + manual WAV header if only using WAV as intermediate. Recommended: render PCM to `[]int16`, skip WAV file entirely for production path, write bytes directly to LameWriter. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| MP3 encoding | Custom MP3 framer | `sjzar/go-lame` v0.0.9 | MP3 frame format is complex; ID3 headers, bit reservoir, psychoacoustic model — thousands of edge cases | +| WAV file format | Custom WAV header writer | `go-audio/wav` (optional) or raw int16 bytes direct to go-lame | WAV header has mandatory chunk offsets that must be backpatched; go-audio/wav handles this | +| Musical interval math | Frequency ratio lookup tables | Use the ratios directly: fifth = ×1.5, octave = ×2 | Pre-computed in config table; no runtime math needed | + +--- + +## Common Pitfalls + +### Pitfall 1: InitParams() Not Called + +**What goes wrong:** `LameWriter.Write()` silently produces empty or corrupted MP3 frames. No panic or error is returned. The MP3 file is created but fails `ffprobe` validation. + +**Why it happens:** go-lame wraps libmp3lame's C API which requires explicit parameter initialization. The Go wrapper does not auto-call it. + +**How to avoid:** In `encode/mp3.go`, make `NewMP3Encoder()` call `InitParams()` internally after all configuration. Callers never need to know about it. + +**Warning signs:** MP3 file is non-zero bytes but `ffprobe` reports "Invalid data found when processing input" or duration is 0. + +### Pitfall 2: Stereo Interleaving Order + +**What goes wrong:** Left and right channels are swapped or mono is written when stereo is expected. Oscillators that should be wide-right sound wide-left. + +**Why it happens:** go-lame expects `[L0, R0, L1, R1, ...]` interleaved. If samples are written as `[L0, L1, ..., R0, R1, ...]` (planar), the decoder interprets the first N/2 bytes as alternating L/R. + +**How to avoid:** Interleave explicitly in the frame render loop: `pcm[i*2] = leftSample; pcm[i*2+1] = rightSample`. + +### Pitfall 3: EMA Never Reaches Whisper Floor on First Window + +**What goes wrong:** During the first window, `currentAmp` starts at 0. If a class has count > 0, the target is above the whisper floor but `currentAmp` starts from 0 — it takes several windows to ramp up. The first second of audio is unnaturally quiet. + +**Why it happens:** EMA is a smoothing filter — it takes time to respond from zero. + +**How to avoid:** Initialize `currentAmp = whisperFloor` for all layers at startup (not 0). This way all layers start at their floor, not silence. D-08 specifies the floor is always present once a class has been seen — pre-setting to whisper floor on construction is consistent with the intent. + +### Pitfall 4: go-audio/wav Requires io.WriteSeeker + +**What goes wrong:** Passing `bytes.Buffer` as the writer to `wav.NewEncoder` causes a compile error or runtime panic because `bytes.Buffer` does not implement `Seek`. + +**Why it happens:** WAV format must backpatch header chunk sizes after the data is written. This requires seeking backwards in the file. + +**How to avoid:** Either write PCM bytes directly to `LameWriter` (skip WAV entirely) or use a `bytes.Buffer` subtype that supports seeking. The simplest production path: render float64 samples → convert to int16 → convert to []byte → `LameWriter.Write()`. The WAV intermediate is optional for debugging. + +### Pitfall 5: Zero-Packet Guard Must Precede File Creation + +**What goes wrong:** The output file (`netsynth-.mp3`) is created on disk, then the zero-packet check fires and returns an error. A zero-byte file is left behind. Confusing to users. + +**Why it happens:** Typical pattern opens the file first, then validates inputs. + +**How to avoid:** Collect all `WindowSnapshot` values from the channel into a slice. Check `totalPackets == 0` across all snapshots. Only then open the output file and begin encoding. In Phase 2's isolated testing, this check is performed before passing snapshots to the encoder. + +### Pitfall 6: gcc Not Installed + +**What goes wrong:** `go build` with `CGO_ENABLED=1` fails with `cgo: C compiler "gcc" not found: exec: "gcc": executable file not found in $PATH`. + +**Why it happens:** `sjzar/go-lame` embeds LAME C source and requires CGo compilation. The build environment on this machine does not have `gcc` installed. + +**How to avoid:** Wave 0 task must install gcc: `sudo apt-get install -y gcc`. + +--- + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go toolchain | All compilation | ✗ | — | Must install; available via apt or tarball | +| gcc / C compiler | `sjzar/go-lame` CGo build | ✗ | — | None — required for go-lame; install via apt | +| ffprobe | OUT-02 test validation (success criterion: "passes ffprobe validation") | ✗ | — | None for automated test; install via apt (ffmpeg package) | +| `sjzar/go-lame` v0.0.9 | MP3 encoding | ✗ (not in go.mod) | — | Wave 0: `go get github.com/sjzar/go-lame@v0.0.9` | +| `go-audio/wav` | WAV intermediate (optional) | ✗ (not in go.mod) | — | Can skip WAV and write PCM bytes directly to LameWriter | + +**Note on Go toolchain:** The project's go.mod exists and was used to build Phase 1 successfully. STATE.md records `[Phase 01]: Go installed to /home/dev/tools/go-install/go (no sudo); PATH export required each session`. However, `/home/dev/tools/` does not currently exist on this machine. The Go binary path needs to be confirmed before Wave 0 executes. The module cache location will also affect where `go get` downloads packages. + +**Missing dependencies with no fallback:** +- `gcc` — blocks go-lame CGo compilation. Install: `sudo apt-get install -y gcc` +- `ffprobe` — blocks MP3 validation in tests. Install: `sudo apt-get install -y ffmpeg` +- Go toolchain — path unknown; needs confirmation before Wave 0 + +**Missing dependencies with fallback:** +- `go-audio/wav` — can be skipped; write PCM bytes directly to LameWriter (saves one dependency) + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Go standard `testing` package (no external framework) | +| Config file | none — uses `go test ./...` | +| Quick run command | `go test ./synth/... ./encode/...` | +| Full suite command | `go test ./...` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| SYNTH-01 | Each class produces a non-zero PCM output with distinct frequency content | unit | `go test ./synth/... -run TestOscillatorDistinct` | ❌ Wave 0 | +| SYNTH-01 | Harmonics produce richer waveform than pure sine | unit | `go test ./synth/... -run TestHarmonics` | ❌ Wave 0 | +| SYNTH-02 | EMA amplitude rises toward target over N samples | unit | `go test ./synth/... -run TestEMAAplitudeRise` | ❌ Wave 0 | +| SYNTH-02 | Whisper floor prevents amplitude reaching zero | unit | `go test ./synth/... -run TestWhisperFloor` | ❌ Wave 0 | +| SYNTH-03 | Mixer sum of 11 max-amplitude layers does not exceed 1.0 | unit | `go test ./synth/... -run TestMixerNoClip` | ❌ Wave 0 | +| SYNTH-03 | Stereo output has distinct L/R channel values for panned sources | unit | `go test ./synth/... -run TestStereoPan` | ❌ Wave 0 | +| OUT-01 | `-o flag` sets output path; default contains timestamp | unit | `go test ./cmd/... -run TestOutputFlag` | ❌ Wave 0 | +| OUT-02 | Encoded bytes form a valid MP3 (ffprobe check) | integration | `go test ./encode/... -run TestMP3Valid` | ❌ Wave 0 | +| OUT-03 | Zero-packet input returns error, no file created | unit | `go test ./encode/... -run TestZeroPacketError` | ❌ Wave 0 | + +### Sampling Rate + +- **Per task commit:** `go test ./synth/...` +- **Per wave merge:** `go test ./...` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `synth/config_test.go` — covers SYNTH-01 (oscillator distinct, harmonics) +- [ ] `synth/layer_test.go` — covers SYNTH-02 (EMA rise, whisper floor) +- [ ] `synth/mixer_test.go` — covers SYNTH-03 (no clip, stereo pan) +- [ ] `encode/mp3_test.go` — covers OUT-02, OUT-03 +- [ ] Environment setup: `sudo apt-get install -y gcc ffmpeg` — required before any build +- [ ] `go get github.com/sjzar/go-lame@v0.0.9` — add to go.mod +- [ ] `go get github.com/go-audio/wav@latest` — add to go.mod (if using WAV intermediate) + +--- + +## Code Examples + +### Full go-lame Encode Path (Verified) + +```go +// Source: pkg.go.dev/github.com/sjzar/go-lame — verified 2026-03-26 +import ( + "encoding/binary" + "os" + "github.com/sjzar/go-lame" +) + +func EncodeMP3(outputPath string, stereoSamples [][2]float64, sampleRate int) error { + f, err := os.Create(outputPath) + if err != nil { + return 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 called after all Set* + + // Convert [][2]float64 → interleaved int16 bytes + pcm := make([]byte, len(stereoSamples)*4) // 2 channels * 2 bytes/sample + for i, frame := range stereoSamples { + l := int16(frame[0] * 32767) + r := int16(frame[1] * 32767) + binary.LittleEndian.PutUint16(pcm[i*4:], uint16(l)) + binary.LittleEndian.PutUint16(pcm[i*4+2:], uint16(r)) + } + + if _, err := wr.Write(pcm); err != nil { + return err + } + return wr.Close() +} +``` + +### EMA Alpha Calculation + +```go +// Source: standard continuous-time EMA derivation +// tau = time constant in seconds (1.0 for 1-second attack/decay feel) +// sampleRate = 44100 +func emaAlpha(tau float64, sampleRate int) float64 { + return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate))) +} +// Result: alpha ≈ 0.0000227 for tau=1.0, SR=44100 +// After 44100 samples (1 second): amplitude reaches 63.2% of target +// After 3 seconds: 95% of target — consistent with "medium responsiveness" D-07 +``` + +### Amplitude Target from WindowSnapshot + +```go +// Source: derived from D-07, D-08, D-09 +func (b *OscillatorBank) updateTargets(snap classify.WindowSnapshot) { + // Find max count across all classes for normalization + var maxCount int64 + for _, count := range snap.Counts { + if count > maxCount { + maxCount = count + } + } + + for _, class := range classify.AllClasses() { + layer := b.layers[class] + count := snap.Counts[class] + + // Mark class as "seen" if it has appeared at all + if count > 0 { + layer.seen = true + } + + var target float64 + if layer.seen { + normalizedRate := 0.0 + if maxCount > 0 { + normalizedRate = float64(count) / float64(maxCount) + } + target = layer.whisperFloor + (1.0-layer.whisperFloor)*normalizedRate + } + // If never seen: target stays 0.0 (no whisper floor yet) + layer.targetAmp = target + } +} +``` + +### Zero-Packet Guard (OUT-03) + +```go +// Collect all snapshots before encoding +func RunSynthesis(snapshots <-chan classify.WindowSnapshot, outputPath string) error { + var allSnaps []classify.WindowSnapshot + var totalPackets int64 + for snap := range snapshots { + allSnaps = append(allSnaps, snap) + totalPackets += snap.TotalPackets + } + + if totalPackets == 0 { + return fmt.Errorf("no packets captured: output MP3 not written (empty capture produces no audio)") + } + + // Now safe to open output file and encode + return encodeAllSnaps(allSnaps, outputPath) +} +``` + +### Output Flag Wiring in main.go + +```go +// Add to main.go — following existing Cobra pattern +var outputPath string + +rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-.mp3)") + +// In run() function — resolve default before synthesis: +if outputPath == "" { + outputPath = fmt.Sprintf("netsynth-%s.mp3", time.Now().Format("20060102-150405")) +} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `go-audio/generator` for oscillators | Hand-rolled phase accumulator | Feb 2026 (archived) | No library dependency; ~20 lines replaces it | +| `google/gopacket` | `gopacket/gopacket` community fork | 2024 | Already using correct fork (in go.mod) | +| Dynamic-linked libmp3lame | `sjzar/go-lame` embedded C source | April 2025 (v0.0.9) | Self-contained CGo; no system package required | + +**Deprecated/outdated:** +- `go-audio/generator`: Archived February 2026. Do not use. Already documented in CLAUDE.md. +- `faiface/beep`: Real-time playback focus — irrelevant for batch file output. + +--- + +## Open Questions + +1. **Go toolchain location on this machine** + - What we know: STATE.md says Go was installed to `/home/dev/tools/go-install/go` and PATH export is required each session. But `/home/dev/tools/` does not exist. + - What's unclear: Was Go installed in this container/environment differently, or does it need re-installation? + - Recommendation: Wave 0 first task should confirm `which go` or install Go fresh. The `go.mod` and `go.sum` exist, suggesting Go was successfully used for Phase 1. + +2. **go-audio/wav vs raw PCM bytes to go-lame** + - What we know: CLAUDE.md recommends go-audio/wav as intermediate. The WAV library requires `io.WriteSeeker` which `bytes.Buffer` does not satisfy. + - What's unclear: Whether the planner should include go-audio/wav as a dependency or go direct PCM → LAME. + - Recommendation: Write PCM as `[]int16` directly to a `[]byte` buffer and pass to `LameWriter.Write()`. This is simpler, eliminates one dependency, and avoids the `io.WriteSeeker` complication. Reserve go-audio/wav for if a WAV debug output feature is wanted later. + +3. **Subjective listening validation** + - What we know: STATE.md flags "Frequency mapping requires subjective listening validation — specific Hz values not determined by research; must test during Phase 2". + - What's unclear: The plan should allocate a manual listening step. Automated tests can verify PCM is non-zero and distinct across classes, but perceptual quality requires human ears. + - Recommendation: Include a Wave N task: "Manual listening test — play generated MP3 with synthetic traffic, verify each class is audibly distinct and the stereo field is perceived." + +--- + +## Project Constraints (from CLAUDE.md) + +The following directives from `CLAUDE.md` constrain this phase. Plans MUST NOT contradict them. + +| Directive | Impact on Phase 2 | +|-----------|-------------------| +| Language: Go — single binary output | No new runtime dependencies; go-lame CGo is compile-time only | +| Audio format: MP3 output (not WAV or raw PCM) | WAV is intermediate only (optional); final output must be .mp3 | +| Non-interactive capture | All PCM is buffered then encoded after capture ends; no streaming | +| `sjzar/go-lame` v0.0.9 — use this, not viert/go-lame or shine-mp3 | Encoder package must import `github.com/sjzar/go-lame` | +| `go-audio/generator` is archived — do NOT use | Oscillator must be hand-rolled (phase accumulator pattern) | +| `faiface/beep`, `dasa.cc/snd` — do NOT use | No real-time audio libraries | +| `go-audio/wav` recommended for intermediate format | Use if WAV intermediate is included; or skip entirely (see Open Question 2) | +| CGO_ENABLED=1 required for go-lame build | Build commands must set CGO_ENABLED=1; gcc must be installed | +| GSD workflow enforcement — use `/gsd:execute-phase` | No direct file edits outside GSD workflow | + +--- + +## Sources + +### Primary (HIGH confidence) +- `pkg.go.dev/github.com/sjzar/go-lame` — API signatures, InitParams requirement, LameWriter pattern, PCM byte format — verified 2026-03-26 +- `pkg.go.dev/github.com/go-audio/wav` — Encoder API, io.WriteSeeker requirement — verified 2026-03-26 +- `/home/dev/workspace/yoloyolo/classify/types.go` — TrafficClass constants, WindowSnapshot struct — direct codebase read +- `/home/dev/workspace/yoloyolo/aggregate/window.go` — Aggregate() signature, channel contract — direct codebase read +- `/home/dev/workspace/yoloyolo/cmd/netsynth/main.go` — existing Cobra flag pattern, pipeline wiring — direct codebase read +- `/home/dev/workspace/yoloyolo/CLAUDE.md` — locked stack decisions, forbidden libraries — direct codebase read + +### Secondary (MEDIUM confidence) +- `.planning/research/ARCHITECTURE.md` — pipeline patterns, per-layer amplitude lerp, anti-patterns — project research document from 2026-03-24 +- `.planning/research/STACK.md` — library rationale, audio synthesis architecture note — project research document from 2026-03-24 +- Standard DSP literature — EMA formula `α = 1 - exp(-1/(τ·SR))`, constant-power pan law `cos/sin` — mathematically stable, textbook-level + +### Tertiary (LOW confidence) +- Frequency assignments (specific Hz values) — derived from D-03 constraints using musical interval ratios; require subjective listening validation + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — locked in CLAUDE.md, APIs verified against pkg.go.dev +- Architecture: HIGH — patterns verified from Phase 1 codebase; math is standard DSP +- Pitfalls: HIGH — InitParams trap verified against go-lame API; others from direct API inspection +- Frequency assignments: LOW — require subjective listening test; values are reasonable starting points + +**Research date:** 2026-03-26 +**Valid until:** 2026-06-26 (stable stack; go-lame and go-audio/wav APIs are stable)