From c8536dac94f482833bb7c87814c6058831fb67c9 Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 26 Mar 2026 13:02:16 +0100 Subject: [PATCH] docs(03): create phase plan --- .planning/ROADMAP.md | 10 +- .../03-01-PLAN.md | 404 ++++++++++++++++++ .../03-02-PLAN.md | 256 +++++++++++ 3 files changed, 667 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/03-pipeline-integration-and-mvp/03-01-PLAN.md create mode 100644 .planning/phases/03-pipeline-integration-and-mvp/03-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 1fb7975..5e253ed 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -62,7 +62,11 @@ Plans: 1. User runs `netsynth -i eth0 -o out.mp3`, generates traffic, presses Ctrl+C, and receives a valid playable MP3 at `out.mp3` 2. Unrecognized traffic patterns are automatically assigned distinct drone tones — unknown traffic is not silent or merged into a single undifferentiated layer 3. The MP3 audio reflects the actual traffic mix — a session with mostly DNS sounds different from one with mostly HTTPS -**Plans**: TBD +**Plans:** 2 plans + +Plans: +- [ ] 03-01-PLAN.md — Extend TrafficClass to 14 classes (hash-bucketed unknown-1 through unknown-4), update synth config with dissonant tones +- [ ] 03-02-PLAN.md — Wire capture pipeline into RunSynthesis, encoding feedback messages, end-to-end MVP verification ### Phase 4: Power User Features **Goal**: Users can scope capture with BPF expressions and sonify historical pcap files @@ -77,11 +81,11 @@ Plans: ## Progress **Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 +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 | 3/3 | Complete | 2026-03-26 | -| 3. Pipeline Integration and MVP | 0/? | Not started | - | +| 3. Pipeline Integration and MVP | 0/2 | In progress | - | | 4. Power User Features | 0/? | Not started | - | diff --git a/.planning/phases/03-pipeline-integration-and-mvp/03-01-PLAN.md b/.planning/phases/03-pipeline-integration-and-mvp/03-01-PLAN.md new file mode 100644 index 0000000..15112a9 --- /dev/null +++ b/.planning/phases/03-pipeline-integration-and-mvp/03-01-PLAN.md @@ -0,0 +1,404 @@ +--- +phase: 03-pipeline-integration-and-mvp +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - classify/types.go + - classify/classifier.go + - classify/classifier_test.go + - synth/config.go + - synth/bank_test.go + - synth/config_test.go +autonomous: true +requirements: [CLAS-02] + +must_haves: + truths: + - "AllClasses() returns exactly 14 classes with no ClassUnknown" + - "Unrecognized TCP/UDP packets are deterministically assigned to ClassUnknown1-4 via hash-bucketing" + - "Packets with no transport layer (ARP etc.) are assigned to an unknown bucket, not silently dropped" + - "Each unknown bucket has a distinct tone in the 850-1100 Hz dissonant range" + - "GainPerLayer is 1/14 so 14 layers mix without clipping" + artifacts: + - path: "classify/types.go" + provides: "ClassUnknown1-4 constants, 14-element AllClasses()" + contains: "ClassUnknown1" + - path: "classify/classifier.go" + provides: "hashBucket() function routing unrecognized traffic" + contains: "func hashBucket" + - path: "synth/config.go" + provides: "FreqConfig entries for all 14 classes, NumLayers=14" + contains: "NumLayers = 14" + - path: "synth/config_test.go" + provides: "Test ensuring every AllClasses() entry has a FreqConfig" + contains: "TestClassFreqConfigsComplete" + key_links: + - from: "classify/classifier.go" + to: "classify/types.go" + via: "hashBucket returns ClassUnknown1-4 constants" + pattern: "ClassUnknown[1-4]" + - from: "synth/config.go" + to: "classify/types.go" + via: "ClassFreqConfigs map keys include all 14 classes" + pattern: "classify\\.ClassUnknown[1-4]" +--- + + +Extend the traffic classification type system from 11 classes to 14 by replacing ClassUnknown with 4 hash-bucketed unknown classes, and add corresponding synthesis tone configurations. + +Purpose: Implements CLAS-02 (auto-clustering) so unrecognized traffic produces distinct drone tones instead of collapsing into a single undifferentiated layer. +Output: Updated classify and synth packages with 14-class type system, hash-bucketing classifier, dissonant tone configs, and comprehensive 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/STATE.md +@.planning/phases/03-pipeline-integration-and-mvp/03-CONTEXT.md + + + + +From classify/types.go (CURRENT — will be modified): +```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" // TO BE REMOVED +) +func AllClasses() []TrafficClass // currently returns 11, will return 14 +``` + +From classify/classifier.go (CURRENT — ClassUnknown return sites at lines 23, 37, 52, 67, 71): +```go +func (c *Classifier) Classify(pkt gopacket.Packet) ClassifiedPacket +// Falls through to ClassUnknown when no rule matches +``` + +From synth/config.go (CURRENT — will be modified): +```go +const NumLayers = 11 +const GainPerLayer = 1.0 / float64(NumLayers) // ~0.0909 +var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{...} // 11 entries +``` + +From encode/mp3.go (NOT modified — consumes AllClasses() via synth.NewBank): +```go +func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error +``` + + + + + + + Task 1: Extend TrafficClass type system and write Wave 0 tests + classify/types.go, classify/classifier_test.go, synth/config_test.go, synth/bank_test.go + classify/types.go, classify/classifier_test.go, synth/bank_test.go, synth/config.go + + - TestAllClassesCount: `len(AllClasses()) == 14` + - TestAllClassesNoPlainUnknown: no element in AllClasses() equals `"unknown"` + - TestAllClassesHasUnknownBuckets: AllClasses() contains `"unknown-1"`, `"unknown-2"`, `"unknown-3"`, `"unknown-4"` + - TestHashBucketDistribution: calling hashBucket with ports 0-999 and protocol "tcp" produces all 4 ClassUnknown1-4 values + - TestClassifyUnknown (UPDATE): buildUnknownPacket returns a class with `strings.HasPrefix(string(got.Class), "unknown-")`, not `ClassUnknown` + - TestNewBankHas11Layers (UPDATE): assert `len(b.layers) == 14` (rename to TestNewBankHas14Layers) + - TestClassFreqConfigsComplete (NEW in synth/config_test.go): every class from `AllClasses()` has a key in `ClassFreqConfigs` + + +**Step 1: Update classify/types.go (per D-04)** + +Remove the `ClassUnknown TrafficClass = "unknown"` constant. Add four new constants: +```go +ClassUnknown1 TrafficClass = "unknown-1" +ClassUnknown2 TrafficClass = "unknown-2" +ClassUnknown3 TrafficClass = "unknown-3" +ClassUnknown4 TrafficClass = "unknown-4" +``` + +Update `AllClasses()` to return 14 elements: +```go +func AllClasses() []TrafficClass { + return []TrafficClass{ + ClassICMP, ClassDNS, ClassHTTPS, ClassHTTP, ClassSSH, + ClassSMTP, ClassNTP, ClassDHCP, ClassOtherTCP, ClassOtherUDP, + ClassUnknown1, ClassUnknown2, ClassUnknown3, ClassUnknown4, + } +} +``` + +**Step 2: Add tests to classify/classifier_test.go** + +Add `TestAllClassesCount` subtest: +```go +func TestAllClassesCount(t *testing.T) { + classes := classify.AllClasses() + if len(classes) != 14 { + t.Errorf("AllClasses() returned %d classes, want 14", len(classes)) + } + // No plain "unknown" should exist + for _, c := range classes { + if c == "unknown" { + t.Error("AllClasses() still contains plain \"unknown\" — should be removed per D-04") + } + } + // All 4 buckets must be present + buckets := map[classify.TrafficClass]bool{ + classify.ClassUnknown1: false, classify.ClassUnknown2: false, + classify.ClassUnknown3: false, classify.ClassUnknown4: false, + } + for _, c := range classes { + if _, ok := buckets[c]; ok { + buckets[c] = true + } + } + for bucket, found := range buckets { + if !found { + t.Errorf("AllClasses() missing bucket %q", bucket) + } + } +} +``` + +Add `TestHashBucketDistribution` (will fail until Task 2 implements hashBucket — that's the TDD red phase, both tasks are in the same plan so this is fine): +```go +func TestHashBucketDistribution(t *testing.T) { + // hashBucket is unexported, so test through Classify with unmatched TCP ports + c := classify.NewClassifier(classify.DefaultRules) + seen := make(map[classify.TrafficClass]bool) + // Try a range of unmatched TCP ports to hit all 4 buckets + for port := uint16(10000); port < 11000; port++ { + pkt := buildTCPPacket(t, port) + got := c.Classify(pkt) + if strings.HasPrefix(string(got.Class), "unknown-") { + seen[got.Class] = true + } + } + for _, bucket := range []classify.TrafficClass{ + classify.ClassUnknown1, classify.ClassUnknown2, + classify.ClassUnknown3, classify.ClassUnknown4, + } { + if !seen[bucket] { + t.Errorf("hashBucket never produced %q across ports 10000-10999", bucket) + } + } +} +``` + +Add `"strings"` to the import block in classifier_test.go. + +Update `TestClassifyUnknown` subtest (line 220-226): replace the assertion with: +```go +t.Run("TestClassifyUnknown", func(t *testing.T) { + pkt := buildUnknownPacket(t) + got := c.Classify(pkt) + if !strings.HasPrefix(string(got.Class), "unknown-") { + t.Errorf("Unknown packet: got class %q, want unknown-N bucket", got.Class) + } +}) +``` + +**Step 3: Update synth/bank_test.go** + +In `TestNewBankHas11Layers` (line 10-21): +- Rename function to `TestNewBankHas14Layers` +- Change assertion from `!= 11` to `!= 14` +- Change error message from `want 11` to `want 14` + +In `TestMixerNoClip` (line 76-102): +- Update comment from `All 11 classes` to `All 14 classes` +- Update `TotalPackets` from `11000` to `14000` + +**Step 4: Create synth/config_test.go** + +New file: +```go +package synth + +import ( + "testing" + "github.com/netsynth/netsynth/classify" +) + +func TestClassFreqConfigsComplete(t *testing.T) { + for _, class := range classify.AllClasses() { + if _, ok := ClassFreqConfigs[class]; !ok { + t.Errorf("ClassFreqConfigs missing entry for class %q", class) + } + } +} + +func TestNumLayersMatchesAllClasses(t *testing.T) { + if NumLayers != len(classify.AllClasses()) { + t.Errorf("NumLayers=%d but AllClasses() has %d entries", NumLayers, len(classify.AllClasses())) + } +} +``` + + + cd /home/dev/workspace/yoloyolo && go vet ./classify/... ./synth/... 2>&1 + + + - classify/types.go contains `ClassUnknown1 TrafficClass = "unknown-1"` through `ClassUnknown4 TrafficClass = "unknown-4"` + - classify/types.go does NOT contain `ClassUnknown TrafficClass = "unknown"` (plain unknown removed) + - classify/types.go AllClasses() returns slice with 14 elements + - classify/classifier_test.go contains `func TestAllClassesCount` + - classify/classifier_test.go contains `func TestHashBucketDistribution` + - classify/classifier_test.go TestClassifyUnknown uses `strings.HasPrefix(string(got.Class), "unknown-")` + - synth/bank_test.go contains `func TestNewBankHas14Layers` (not 11) + - synth/bank_test.go TestNewBankHas14Layers asserts `!= 14` + - synth/config_test.go contains `func TestClassFreqConfigsComplete` + - synth/config_test.go contains `func TestNumLayersMatchesAllClasses` + - `go vet ./classify/... ./synth/...` exits 0 (compiles cleanly) + + Type system extended to 14 classes, all Wave 0 tests written, code compiles. Some tests will fail (RED phase) until Task 2 implements hashBucket and updates config. + + + + Task 2: Implement hash-bucketing and update synth config for 14 layers + classify/classifier.go, synth/config.go + classify/classifier.go, synth/config.go, classify/types.go, classify/classifier_test.go, synth/config_test.go, synth/bank_test.go + +**Step 1: Add hashBucket function to classify/classifier.go (per D-01/D-03)** + +Add this unexported function at the bottom of classifier.go: +```go +// hashBucket maps an unrecognized packet to one of 4 unknown traffic classes. +// Deterministic: same (dstPort, protocol) always maps to the same bucket. +// ARP and other non-transport packets have dstPort=0, protocol="" -> bucket 0 (ClassUnknown1). +func hashBucket(dstPort uint16, protocol string) TrafficClass { + var protoNum uint16 + switch protocol { + case "tcp": + protoNum = 6 + case "udp": + protoNum = 17 + case "icmp": + protoNum = 1 + } + h := uint32(dstPort)*31 + uint32(protoNum)*7 + switch h % 4 { + case 0: + return ClassUnknown1 + case 1: + return ClassUnknown2 + case 2: + return ClassUnknown3 + default: + return ClassUnknown4 + } +} +``` + +**Step 2: Replace all ClassUnknown return sites in Classify() method** + +There are 4 sites where ClassUnknown is used in classifier.go: + +1. Line 23 — initial default: change `ClassUnknown` to `ClassUnknown1` (temporary; this gets overwritten by the final fallthrough, but the initial value doesn't matter since every code path sets Class before returning. However, to avoid a stale reference, use `""` or just leave the field unset since the default zero value is fine). + + Actually, the simplest approach: change the initial default at line 23 to empty string `""`, then ensure every return path sets the class. The 4 return paths are: + + a. **ICMP no-rule-match (line 37):** `return result` after ICMP rule loop fails. Replace with: + ```go + result.Class = hashBucket(result.DstPort, result.Protocol) + return result + ``` + + b. **TCP no-rule-match (line 52):** `return result` after TCP rule loop. Replace with: + ```go + result.Class = hashBucket(result.DstPort, result.Protocol) + return result + ``` + + c. **UDP no-rule-match (line 67):** `return result` after UDP rule loop. Replace with: + ```go + result.Class = hashBucket(result.DstPort, result.Protocol) + return result + ``` + + d. **No transport layer (line 71):** `return result` at the very end. Replace with: + ```go + result.Class = hashBucket(result.DstPort, result.Protocol) + return result + ``` + + Remove the initial `Class: ClassUnknown` at line 23. Instead set `Class: ""` or omit the field (zero value for string is ""). The `Classify` doc comment referencing "Per D-03: returns ClassUnknown" should be updated to "Per D-03: returns ClassUnknown1-4 via hash-bucketing". + +**Step 3: Update synth/config.go (per D-02/D-05/D-06)** + +Replace `NumLayers = 11` with `NumLayers = 14`. + +This automatically changes `GainPerLayer = 1.0 / float64(NumLayers)` to ~0.0714. + +Remove the `classify.ClassUnknown` entry from `ClassFreqConfigs`. Add 4 new entries: + +```go +// D-05: Unknown buckets in 850-1100 Hz dissonant range, detuned intervals +// D-06: Same dissonant harmonic character {1,1.0},{2,0.8},{3,0.4} for all 4 +classify.ClassUnknown1: {862.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.6}, +classify.ClassUnknown2: {920.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.6}, +classify.ClassUnknown3: {981.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.9}, +classify.ClassUnknown4: {1047.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, -0.9}, +``` + +Remove the old `classify.ClassUnknown` entry and its comment about 437 Hz / D-04 beating. + + + cd /home/dev/workspace/yoloyolo && go test ./classify/... ./synth/... -count=1 -v 2>&1 | tail -40 + + + - classify/classifier.go contains `func hashBucket(dstPort uint16, protocol string) TrafficClass` + - classify/classifier.go does NOT contain the string `ClassUnknown` without a digit suffix (no bare ClassUnknown references) + - classify/classifier.go hashBucket uses `uint32(dstPort)*31 + uint32(protoNum)*7` and `h % 4` + - synth/config.go contains `NumLayers = 14` + - synth/config.go contains `classify.ClassUnknown1:` through `classify.ClassUnknown4:` + - synth/config.go ClassUnknown1 BaseHz is 862.0, ClassUnknown2 is 920.0, ClassUnknown3 is 981.0, ClassUnknown4 is 1047.0 + - synth/config.go does NOT contain `classify.ClassUnknown:` (old entry removed) + - `go test ./classify/... -count=1` passes (all classifier tests green including TestHashBucketDistribution, TestAllClassesCount, TestClassifyUnknown) + - `go test ./synth/... -count=1` passes (TestNewBankHas14Layers, TestClassFreqConfigsComplete, TestNumLayersMatchesAllClasses, TestMixerNoClip all green) + + Hash-bucketing classifier routes unrecognized traffic to 4 distinct unknown classes. Synth config has tone entries for all 14 classes. All classify and synth tests pass. + + + + + +```bash +# All classify and synth tests pass +go test ./classify/... ./synth/... -count=1 + +# Full suite still passes (no regressions in encode, aggregate, capture) +go test ./... -count=1 + +# No bare ClassUnknown references remain (should only find ClassUnknown1-4) +grep -rn 'ClassUnknown[^1234]' classify/ synth/ encode/ cmd/ aggregate/ | grep -v '_test.go.*unknown-' | grep -v '// ' +``` + + + +- AllClasses() returns 14 classes; ClassUnknown is fully removed +- hashBucket deterministically maps (dstPort, protocol) to ClassUnknown1-4 +- All 4 unknown buckets have FreqConfig entries in 850-1100 Hz range +- GainPerLayer = 1/14; TestMixerNoClip passes with 14 layers +- All tests in classify/ and synth/ pass +- No bare ClassUnknown references remain in production code + + + +After completion, create `.planning/phases/03-pipeline-integration-and-mvp/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-pipeline-integration-and-mvp/03-02-PLAN.md b/.planning/phases/03-pipeline-integration-and-mvp/03-02-PLAN.md new file mode 100644 index 0000000..a667047 --- /dev/null +++ b/.planning/phases/03-pipeline-integration-and-mvp/03-02-PLAN.md @@ -0,0 +1,256 @@ +--- +phase: 03-pipeline-integration-and-mvp +plan: 02 +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - cmd/netsynth/main.go +autonomous: false +requirements: [CAPT-03] + +must_haves: + truths: + - "User runs netsynth -i -o out.mp3, generates traffic, presses Ctrl+C, and receives a valid playable MP3" + - "Protocol summary prints to stderr BEFORE encoding begins" + - "User sees 'Encoding N windows to ...' status message during encoding" + - "User sees 'Saved (Xs, N KB, encoded in Xs)' confirmation after encoding" + - "Zero-packet captures produce an error message, not a corrupt file" + artifacts: + - path: "cmd/netsynth/main.go" + provides: "End-to-end pipeline wiring and encoding feedback" + contains: "encode.RunSynthesis" + key_links: + - from: "cmd/netsynth/main.go" + to: "encode/mp3.go" + via: "encode.RunSynthesis(collectedSnapshots, outputPath)" + pattern: "encode\\.RunSynthesis" + - from: "cmd/netsynth/main.go" + to: "aggregate/summary.go" + via: "PrintSummary called before RunSynthesis (D-08)" + pattern: "PrintSummary.*\n.*Encoding" +--- + + +Wire the capture-classify-aggregate pipeline into the audio synthesis engine, completing the v1 MVP end-to-end flow. Add encoding progress feedback messages per D-07/D-08/D-09. + +Purpose: Implements CAPT-03 — the final integration that makes `netsynth -i eth0 -o out.mp3` produce a real audio file from live network traffic. +Output: Updated main.go with RunSynthesis call, encoding status/saved messages, and correct summary ordering. + + + +@$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/03-pipeline-integration-and-mvp/03-CONTEXT.md +@.planning/phases/03-pipeline-integration-and-mvp/03-01-SUMMARY.md + + + + +From encode/mp3.go: +```go +// RunSynthesis consumes a slice of WindowSnapshots, renders audio via OscillatorBank, +// and encodes to MP3 at outputPath. +// Returns an error if zero packets were captured (OUT-03). +func RunSynthesis(snapshots []classify.WindowSnapshot, outputPath string) error +``` + +From aggregate/accumulator.go: +```go +const DefaultWindowMs = 500 +``` + +From aggregate/summary.go: +```go +func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64) +func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot) +``` + +From cmd/netsynth/main.go (CURRENT — lines 91-109 are the integration zone): +```go +// Accumulate snapshots for synthesis (Phase 3 will pass to encode.RunSynthesis) +totals := make(map[classify.TrafficClass]int64) +var collectedSnapshots []classify.WindowSnapshot +for snap := range snapshots { + collectedSnapshots = append(collectedSnapshots, snap) + aggregate.AccumulateTotals(totals, snap) +} + +// TODO(phase-3): Pass collectedSnapshots to encode.RunSynthesis(collectedSnapshots, outputPath) +_ = collectedSnapshots + +// Print exit summary (CLAS-03) +dropped := atomic.LoadInt64(droppedPtr) +if dropped > 0 { + fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped) +} +aggregate.PrintSummary(os.Stderr, totals) + +return nil +``` + + + + + + + Task 1: Wire RunSynthesis and add encoding feedback messages + cmd/netsynth/main.go + cmd/netsynth/main.go, encode/mp3.go, aggregate/summary.go + +Modify `cmd/netsynth/main.go` to replace the TODO stub (lines 99-109) with the complete pipeline wiring and encoding feedback. The changes are: + +**Step 1: Add import** + +Add `"github.com/netsynth/netsynth/encode"` to the import block. The `time` import already exists. + +**Step 2: Replace lines 99-109 with the following sequence** + +Remove: +```go +// TODO(phase-3): Pass collectedSnapshots to encode.RunSynthesis(collectedSnapshots, outputPath) +_ = collectedSnapshots + +// Print exit summary (CLAS-03) +dropped := atomic.LoadInt64(droppedPtr) +if dropped > 0 { + fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped) +} +aggregate.PrintSummary(os.Stderr, totals) + +return nil +``` + +Replace with: +```go +// D-08: Print protocol summary BEFORE encoding — user sees stats immediately +dropped := atomic.LoadInt64(droppedPtr) +if dropped > 0 { + fmt.Fprintf(os.Stderr, "\nWarning: %d packets dropped (channel buffer full)\n", dropped) +} +aggregate.PrintSummary(os.Stderr, totals) + +// D-07: Encoding status line +fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n", len(collectedSnapshots), outputPath) +encodeStart := time.Now() +if err := encode.RunSynthesis(collectedSnapshots, outputPath); err != nil { + return fmt.Errorf("synthesis failed: %w", err) +} +encodeElapsed := time.Since(encodeStart) + +// D-09: Saved confirmation with path, audio duration, file size, encoding time +audioDuration := float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0 +info, statErr := os.Stat(outputPath) +if statErr != nil { + return fmt.Errorf("stat output file: %w", statErr) +} +fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n", + outputPath, audioDuration, info.Size()/1024, encodeElapsed.Seconds()) +return nil +``` + +**Step 3: Update the Long description** + +Change line 32 from: +```go +Long: "NetSynth captures network traffic, classifies it by protocol, and (in future phases) synthesizes an ambient MP3 soundscape.", +``` +to: +```go +Long: "NetSynth captures network traffic, classifies it by protocol, and synthesizes an ambient MP3 soundscape.", +``` + +**Key details:** +- `encode.RunSynthesis` already handles the zero-packet guard (returns error, no corrupt file created) — we just propagate the error via `fmt.Errorf("synthesis failed: %w", err)` +- Audio duration is computed from snapshot count, NOT wall-clock time: `float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0` — uses float64 to avoid integer truncation for short captures (Pitfall 4) +- `os.Stat` is only called AFTER confirming `RunSynthesis` returned nil (Pitfall 5) +- The `_ = collectedSnapshots` line and the TODO comment are completely removed + + + cd /home/dev/workspace/yoloyolo && go build -o /tmp/netsynth-test ./cmd/netsynth && echo "BUILD OK" && go test ./... -count=1 2>&1 | tail -20 + + + - cmd/netsynth/main.go contains `"github.com/netsynth/netsynth/encode"` in imports + - cmd/netsynth/main.go contains `encode.RunSynthesis(collectedSnapshots, outputPath)` + - cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Encoding %d windows to %s...\n"` + - cmd/netsynth/main.go contains `fmt.Fprintf(os.Stderr, "Saved %s (%.1fs, %d KB, encoded in %.1fs)\n"` + - cmd/netsynth/main.go does NOT contain `TODO(phase-3)` + - cmd/netsynth/main.go does NOT contain `_ = collectedSnapshots` + - cmd/netsynth/main.go `PrintSummary` call appears BEFORE `encode.RunSynthesis` call (D-08) + - cmd/netsynth/main.go audioDuration uses `float64(len(collectedSnapshots)) * float64(aggregate.DefaultWindowMs) / 1000.0` (not integer division) + - `go build ./cmd/netsynth` exits 0 + - `go test ./... -count=1` all packages pass + + The complete v1 MVP pipeline is wired: capture -> classify -> aggregate -> synthesize -> encode MP3. Protocol summary prints before encoding. Encoding status and saved confirmation messages display path, audio duration, file size, and encoding time. Binary builds successfully. All tests pass. + + + + Task 2: Verify end-to-end MVP flow + cmd/netsynth/main.go + cmd/netsynth/main.go + +Human verification of the complete end-to-end MVP flow. The binary has been built and all automated tests pass. This checkpoint verifies the live capture-to-MP3 pipeline works with real network traffic and produces the expected UX output. + + Complete NetSynth v1 MVP: live capture -> classification -> audio synthesis -> MP3 output with encoding feedback + + 1. Build the binary: `go build -o ./netsynth ./cmd/netsynth` + 2. Run with loopback interface: `sudo ./netsynth -i lo -o /tmp/test-mvp.mp3` + 3. In another terminal, generate some traffic: `ping -c 5 127.0.0.1` and `curl http://127.0.0.1:80 2>/dev/null || true` + 4. Press Ctrl+C in the netsynth terminal + 5. Verify stderr output shows: + - Protocol summary (packet counts per class) printed FIRST + - "Encoding N windows to /tmp/test-mvp.mp3..." message + - "Saved /tmp/test-mvp.mp3 (Xs, N KB, encoded in Xs)" confirmation + 6. Verify the MP3 file exists and has non-zero size: `ls -la /tmp/test-mvp.mp3` + 7. Optionally validate with ffprobe: `ffprobe /tmp/test-mvp.mp3 2>&1 | head -20` + 8. Test zero-packet case: run and immediately Ctrl+C (no traffic): should show error, no file created + + + cd /home/dev/workspace/yoloyolo && go build -o /tmp/netsynth-verify ./cmd/netsynth && echo "Binary builds OK" + + + - Binary builds without errors + - Running with loopback + traffic + Ctrl+C produces a non-zero MP3 file + - stderr shows protocol summary, then "Encoding...", then "Saved..." in that order + - Zero-packet run shows error message, no file created + + User has verified the end-to-end MVP flow works with live traffic on loopback interface. + Type "approved" or describe issues + + + + + +```bash +# Full test suite +go test ./... -count=1 + +# Binary builds +go build -o /tmp/netsynth-verify ./cmd/netsynth + +# No TODO(phase-3) remaining +grep -rn 'TODO(phase-3)' cmd/ classify/ synth/ encode/ aggregate/ + +# No bare ClassUnknown references (from Plan 01, verify still clean) +grep -rn 'ClassUnknown[^1234]' classify/ synth/ encode/ cmd/ aggregate/ | grep -v test | grep -v '//' +``` + + + +- Binary builds and runs: `netsynth -i lo -o out.mp3` produces a valid MP3 +- Protocol summary appears before encoding messages on stderr +- Saved line shows path, audio duration, file size, encoding time +- Zero-packet capture returns error without creating a file +- All tests pass (`go test ./... -count=1`) +- No TODO(phase-3) stubs remain + + + +After completion, create `.planning/phases/03-pipeline-integration-and-mvp/03-02-SUMMARY.md` +