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

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-audio-synthesis-engine 03 execute 3
02-02
encode/mp3.go
encode/mp3_test.go
cmd/netsynth/main.go
true
OUT-01
OUT-02
OUT-03
truths artifacts key_links
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
path provides contains
encode/mp3.go EncodeMP3 function and RunSynthesis orchestrator func EncodeMP3
path provides contains
encode/mp3_test.go Integration test with ffprobe validation and zero-packet guard test TestMP3Valid
path provides contains
cmd/netsynth/main.go -o flag wiring with timestamp default, snapshot slice accumulator stub outputPath
from to via pattern
encode/mp3.go synth/bank.go RunSynthesis calls bank.RenderWindow for each snapshot bank.RenderWindow
from to via pattern
encode/mp3.go synth/mixer.go Uses StereoFramesToInt16Bytes for PCM conversion StereoFramesToInt16Bytes
from to via pattern
cmd/netsynth/main.go encode/mp3.go Will wire RunSynthesis in Phase 3; -o flag defined now 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/02-audio-synthesis-engine/02-CONTEXT.md @.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md @.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md @.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:

func StereoFramesToInt16Bytes(frames [][2]float64) []byte

From synth/config.go:

const SampleRate = 44100
const SamplesPerWindow = 22050

From classify/types.go:

type WindowSnapshot struct {
    Counts       map[TrafficClass]int64
    TotalPackets int64
    WindowIndex  int
}

From github.com/sjzar/go-lame:

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:
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 <acceptance_criteria> - 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 </acceptance_criteria> 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 and snapshot accumulator stub 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:

    var outputPath string
    
  2. Add the flag registration in main() after the existing flags:

    rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-<timestamp>.mp3)")
    
  3. Add the timestamp default resolution at the top of run(), after the --list-interfaces check and before the interface check:

    // 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 snapshot slice accumulator alongside the existing snapshot consumption loop, and a TODO for Phase 3 wiring. Replace the current for snap := range snapshots block to also collect snapshots into a slice:

    // Accumulate snapshots for synthesis (Phase 3 will pass to encode.RunSynthesis)
    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)
    

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" <acceptance_criteria> - 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" - cmd/netsynth/main.go contains var collectedSnapshots []classify.WindowSnapshot - cmd/netsynth/main.go contains TODO(phase-3) referencing encode.RunSynthesis - go build ./cmd/netsynth/ exits 0 - go run ./cmd/netsynth/ --help output contains -o, --output </acceptance_criteria> The -o/--output flag is registered in Cobra, defaults to netsynth-.mp3, visible in --help. Snapshot slice accumulator stub is in place for Phase 3 wiring. Build succeeds.

- `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 - `grep "collectedSnapshots" cmd/netsynth/main.go` confirms snapshot accumulator stub

<success_criteria>

  • 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)
  • Snapshot slice accumulator ready for Phase 3 wiring
  • Full test suite passes: CGO_ENABLED=1 go test ./...
  • Complete Phase 2 audio pipeline validated end-to-end against synthetic data </success_criteria>
After completion, create `.planning/phases/02-audio-synthesis-engine/02-03-SUMMARY.md`