300 lines
12 KiB
Markdown
300 lines
12 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
<!-- From synth/bank.go (created in Plan 02) -->
|
|
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 go-lame (external dependency) -->
|
|
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
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: MP3 encoder package with zero-packet guard and integration test</name>
|
|
<files>encode/mp3.go, encode/mp3_test.go</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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 <file>` 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.
|
|
</behavior>
|
|
<action>
|
|
**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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/dev/workspace/yoloyolo && CGO_ENABLED=1 go test ./encode/... -v -count=1</automated>
|
|
</verify>
|
|
<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>
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Wire -o output flag into CLI</name>
|
|
<files>cmd/netsynth/main.go</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<action>
|
|
Add the `-o` / `--output` flag to main.go. Per D-15: defaults to `netsynth-<timestamp>.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-<timestamp>.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.
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<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"`
|
|
- `go build ./cmd/netsynth/` exits 0
|
|
- `go run ./cmd/netsynth/ --help` output contains `-o, --output`
|
|
</acceptance_criteria>
|
|
<done>The -o/--output flag is registered in Cobra, defaults to netsynth-<timestamp>.mp3, visible in --help. Build succeeds. Phase 3 will wire it to RunSynthesis.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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)
|
|
- Full test suite passes: `CGO_ENABLED=1 go test ./...`
|
|
- Complete Phase 2 audio pipeline validated end-to-end against synthetic data
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-audio-synthesis-engine/02-03-SUMMARY.md`
|
|
</output>
|