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 |
|
|
true |
|
|
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
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:
RunSynthesistakes a[]classify.WindowSnapshotslice (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.EncodeMP3is a separate function so it can be tested independently with raw frames.- PCM bytes are written in a single
Writecall (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.
-
Add a package-level var:
var outputPath string -
Add the flag registration in
main()after the existing flags:rootCmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output MP3 file path (default: netsynth-<timestamp>.mp3)") -
Add the timestamp default resolution at the top of
run(), after the--list-interfacescheck 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")) } -
Add
"time"to the imports. -
Add a snapshot slice accumulator alongside the existing snapshot consumption loop, and a TODO for Phase 3 wiring. Replace the current
for snap := range snapshotsblock 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.
<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>