docs(02): create phase plan — 3 plans across 3 waves
This commit is contained in:
@@ -47,8 +47,12 @@ Plans:
|
||||
3. Drone layer amplitude rises and falls with traffic volume over time — sustained traffic sounds louder, quiet periods fade
|
||||
4. User can specify output path via `-o` flag; it defaults to `netsynth-<timestamp>.mp3` when omitted
|
||||
5. An empty (zero-packet) input produces a clear error message instead of a corrupt or zero-byte MP3
|
||||
**Plans**: TBD
|
||||
**UI hint**: no
|
||||
**Plans:** 3 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 02-01-PLAN.md — Environment setup (gcc, ffprobe, go-lame), synth config table, oscillator, EMA layer with tests
|
||||
- [ ] 02-02-PLAN.md — Stereo mixer (constant-power panning), OscillatorBank multi-layer rendering with tests
|
||||
- [ ] 02-03-PLAN.md — MP3 encoder package, zero-packet guard, -o CLI flag, ffprobe integration test
|
||||
|
||||
### Phase 3: Pipeline Integration and MVP
|
||||
**Goal**: Live capture flows end-to-end into audio synthesis — the complete v1 MVP: run, capture, Ctrl+C, get an MP3
|
||||
@@ -78,6 +82,6 @@ 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 | 0/? | Not started | - |
|
||||
| 2. Audio Synthesis Engine | 0/3 | Planning complete | - |
|
||||
| 3. Pipeline Integration and MVP | 0/? | Not started | - |
|
||||
| 4. Power User Features | 0/? | Not started | - |
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
---
|
||||
phase: 02-audio-synthesis-engine
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- synth/config.go
|
||||
- synth/oscillator.go
|
||||
- synth/layer.go
|
||||
- synth/config_test.go
|
||||
- synth/layer_test.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
autonomous: true
|
||||
requirements: [SYNTH-01, SYNTH-02]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Each of 11 traffic classes has a unique frequency, harmonic profile, and pan position defined"
|
||||
- "Phase-accumulator oscillator produces non-zero PCM samples with harmonics"
|
||||
- "EMA amplitude smoothing moves current amplitude toward target over time"
|
||||
- "Whisper floor prevents amplitude from reaching zero once a class has been seen"
|
||||
- "go-lame v0.0.9 is in go.mod and CGo build succeeds"
|
||||
artifacts:
|
||||
- path: "synth/config.go"
|
||||
provides: "FreqConfig, HarmonicDef types and ClassFreqConfigs table for all 11 classes"
|
||||
contains: "ClassFreqConfigs"
|
||||
- path: "synth/oscillator.go"
|
||||
provides: "Phase-accumulator oscillator with Advance method"
|
||||
contains: "func (o *Oscillator) Advance"
|
||||
- path: "synth/layer.go"
|
||||
provides: "Layer struct with EMA amplitude, whisper floor, target updates"
|
||||
contains: "func (l *Layer) UpdateTarget"
|
||||
key_links:
|
||||
- from: "synth/config.go"
|
||||
to: "classify/types.go"
|
||||
via: "import classify.TrafficClass as map key"
|
||||
pattern: "map\\[classify\\.TrafficClass\\]FreqConfig"
|
||||
- from: "synth/layer.go"
|
||||
to: "synth/oscillator.go"
|
||||
via: "Layer embeds/uses Oscillator"
|
||||
pattern: "Oscillator"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the synthesis foundation: frequency/harmonic config table, phase-accumulator oscillator, and EMA amplitude layer — all with tests. Also install gcc, ffprobe, and go-lame dependency.
|
||||
|
||||
Purpose: SYNTH-01 (distinct drone layers) and SYNTH-02 (amplitude dynamics) depend on these building blocks. Everything in this plan is tested in isolation before the mixer and encoder are built in Plan 02.
|
||||
|
||||
Output: `synth/config.go`, `synth/oscillator.go`, `synth/layer.go` with corresponding test files. Environment ready for CGo builds.
|
||||
</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/STATE.md
|
||||
@.planning/phases/02-audio-synthesis-engine/02-CONTEXT.md
|
||||
@.planning/phases/02-audio-synthesis-engine/02-RESEARCH.md
|
||||
|
||||
@classify/types.go
|
||||
|
||||
<interfaces>
|
||||
<!-- From classify/types.go — the input contract for synthesis -->
|
||||
From classify/types.go:
|
||||
```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"
|
||||
)
|
||||
|
||||
func AllClasses() []TrafficClass { ... }
|
||||
|
||||
type WindowSnapshot struct {
|
||||
Counts map[TrafficClass]int64
|
||||
TotalPackets int64
|
||||
WindowIndex int
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Environment setup and dependency installation</name>
|
||||
<files>go.mod, go.sum</files>
|
||||
<read_first>
|
||||
- go.mod (current dependencies)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (environment notes)
|
||||
</read_first>
|
||||
<action>
|
||||
1. Confirm Go is available. Check `which go` — if not found, check common paths: `/usr/local/go/bin/go`, `/home/dev/tools/go-install/go/bin/go`, or install via `sudo apt-get install -y golang-go`. Export PATH so `go` is accessible for subsequent commands.
|
||||
|
||||
2. Install gcc (required for sjzar/go-lame CGo build):
|
||||
```
|
||||
sudo apt-get update && sudo apt-get install -y gcc
|
||||
```
|
||||
|
||||
3. Install ffprobe (required for MP3 validation in tests):
|
||||
```
|
||||
sudo apt-get install -y ffmpeg
|
||||
```
|
||||
|
||||
4. Add go-lame to go.mod:
|
||||
```
|
||||
go get github.com/sjzar/go-lame@v0.0.9
|
||||
```
|
||||
|
||||
5. Run `go mod tidy` to clean up.
|
||||
|
||||
6. Verify CGo build works by running `CGO_ENABLED=1 go build ./...` — should succeed with no errors.
|
||||
|
||||
NOTE: Do NOT add go-audio/wav. Per research recommendation, skip the WAV intermediate and write PCM bytes directly to LameWriter. This eliminates a dependency and avoids the io.WriteSeeker complication.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go version && gcc --version && ffprobe -version && grep "go-lame" go.mod && CGO_ENABLED=1 go build ./...</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go version` outputs a version string containing "go1.2"
|
||||
- `gcc --version` outputs a version string
|
||||
- `ffprobe -version` outputs a version string
|
||||
- go.mod contains the line `github.com/sjzar/go-lame v0.0.9`
|
||||
- `CGO_ENABLED=1 go build ./...` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Go, gcc, and ffprobe are available. go-lame v0.0.9 is in go.mod. CGo build succeeds.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Synth config, oscillator, and layer with tests</name>
|
||||
<files>synth/config.go, synth/oscillator.go, synth/layer.go, synth/config_test.go, synth/layer_test.go</files>
|
||||
<read_first>
|
||||
- classify/types.go (TrafficClass constants, AllClasses, WindowSnapshot)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (patterns 1-2, frequency table, EMA formula)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (decisions D-01 through D-09)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestAllClassesHaveConfig: Every class from classify.AllClasses() has an entry in ClassFreqConfigs
|
||||
- TestFrequenciesInRange: All BaseHz values are within 60-800 Hz (per D-01)
|
||||
- TestFrequenciesUnique: No two classes share the same BaseHz
|
||||
- TestHarmonicsNonEmpty: Every class has at least 2 HarmonicDef entries (fundamental + at least 1 harmonic, per D-05)
|
||||
- TestPanPositionsInRange: All Pan values in [-1.0, 1.0]
|
||||
- TestOscillatorAdvance: Oscillator at 440 Hz / 44100 SR produces non-zero samples; 100 samples have both positive and negative values (sine wave)
|
||||
- TestOscillatorPhaseWrap: After 44100 advances, phase stays in [0, 1)
|
||||
- TestOscillatorDistinctFreqs: Oscillators at 65 Hz and 440 Hz produce different sample sequences
|
||||
- TestEMAAmplitudeRise: Layer with target=1.0 and currentAmp=0.0 has currentAmp > 0.5 after 44100 samples (1 second at tau=1.0)
|
||||
- TestEMAAmplitudeDecay: Layer with target=0.03 (whisper floor) and currentAmp=1.0 has currentAmp < 0.5 after 44100 samples
|
||||
- TestWhisperFloor: Layer marked as seen=true with target set from zero-count snapshot has targetAmp >= whisperFloor (0.03)
|
||||
- TestWhisperFloorNotSeenIsZero: Layer with seen=false has targetAmp == 0.0
|
||||
</behavior>
|
||||
<action>
|
||||
**synth/config.go** — Create the frequency/harmonic/pan configuration table. Per D-01 through D-06 and D-11/D-12:
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import "github.com/netsynth/netsynth/classify"
|
||||
|
||||
const (
|
||||
SampleRate = 44100 // D-13: CD quality
|
||||
WindowMs = 500 // matches aggregate.DefaultWindowMs
|
||||
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
||||
NumLayers = 11
|
||||
GainPerLayer = 1.0 / float64(NumLayers) // D-10: ~0.0909
|
||||
WhisperFloor = 0.03 // D-08/D-09: 3% of max amplitude
|
||||
)
|
||||
|
||||
type HarmonicDef struct {
|
||||
Ratio int // harmonic number: 1=fundamental, 2=octave, 3=fifth+octave, etc.
|
||||
Amplitude float64 // relative weight
|
||||
}
|
||||
|
||||
type FreqConfig struct {
|
||||
BaseHz float64
|
||||
Harmonics []HarmonicDef
|
||||
Pan float64 // [-1, 1]: -1=full left, 0=center, +1=full right
|
||||
}
|
||||
|
||||
// ClassFreqConfigs maps each traffic class to its synthesis parameters.
|
||||
// Frequencies use musical intervals per D-02/D-03. Harmonics per D-05/D-06.
|
||||
// Pan positions per D-12.
|
||||
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{
|
||||
classify.ClassICMP: {65.0, []HarmonicDef{{1, 1.0}, {2, 0.4}, {3, 0.15}}, 0.0},
|
||||
classify.ClassDNS: {110.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {3, 0.25}}, -0.2},
|
||||
classify.ClassHTTPS: {175.0, []HarmonicDef{{1, 1.0}, {2, 0.6}, {3, 0.3}}, 0.2},
|
||||
classify.ClassHTTP: {220.0, []HarmonicDef{{1, 1.0}, {2, 0.5}, {4, 0.2}}, -0.35},
|
||||
classify.ClassSSH: {330.0, []HarmonicDef{{1, 1.0}, {3, 0.6}, {5, 0.3}}, 0.35},
|
||||
classify.ClassSMTP: {440.0, []HarmonicDef{{1, 1.0}, {2, 0.3}, {3, 0.1}}, -0.55},
|
||||
classify.ClassNTP: {520.0, []HarmonicDef{{1, 1.0}, {2, 0.25}}, 0.55},
|
||||
classify.ClassDHCP: {600.0, []HarmonicDef{{1, 1.0}, {2, 0.35}, {3, 0.15}}, -0.75},
|
||||
classify.ClassOtherTCP: {700.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, 0.75},
|
||||
classify.ClassOtherUDP: {780.0, []HarmonicDef{{1, 1.0}, {2, 0.2}}, -0.75},
|
||||
classify.ClassUnknown: {437.0, []HarmonicDef{{1, 1.0}, {2, 0.8}, {3, 0.4}}, 0.0},
|
||||
// D-04: 437 Hz is ~12 cents flat from A4 (440 Hz/SMTP).
|
||||
// Creates 3 Hz beating when SMTP is present = dissonant "doesn't belong" signal.
|
||||
}
|
||||
```
|
||||
|
||||
**synth/oscillator.go** — Phase-accumulator oscillator (Pattern 1 from research):
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import "math"
|
||||
|
||||
type Oscillator struct {
|
||||
phase float64
|
||||
freq float64
|
||||
sr float64
|
||||
}
|
||||
|
||||
func NewOscillator(freq float64, sampleRate int) *Oscillator {
|
||||
return &Oscillator{freq: freq, sr: float64(sampleRate)}
|
||||
}
|
||||
|
||||
// Advance returns one sample: fundamental + harmonics summed and normalized to [-1, 1].
|
||||
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
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Phase returns the current phase (for testing).
|
||||
func (o *Oscillator) Phase() float64 {
|
||||
return o.phase
|
||||
}
|
||||
```
|
||||
|
||||
**synth/layer.go** — Layer with EMA amplitude smoothing (Pattern 2):
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import "math"
|
||||
|
||||
// EMAAlpha computes the per-sample smoothing coefficient for a given time constant.
|
||||
// tau=1.0 at SR=44100 gives alpha ~0.0000227 (63% of target reached in 1 second). Per D-07.
|
||||
func EMAAlpha(tau float64, sampleRate int) float64 {
|
||||
return 1.0 - math.Exp(-1.0/(tau*float64(sampleRate)))
|
||||
}
|
||||
|
||||
type Layer struct {
|
||||
Config FreqConfig
|
||||
Osc *Oscillator
|
||||
currentAmp float64
|
||||
targetAmp float64
|
||||
alpha float64 // EMA coefficient
|
||||
seen bool // whether this class has ever had count > 0
|
||||
whisper float64 // whisper floor amplitude (D-08)
|
||||
}
|
||||
|
||||
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer {
|
||||
return &Layer{
|
||||
Config: cfg,
|
||||
Osc: NewOscillator(cfg.BaseHz, sampleRate),
|
||||
alpha: EMAAlpha(tau, sampleRate),
|
||||
whisper: WhisperFloor,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateTarget sets the target amplitude from a packet count and max count across all classes.
|
||||
// Per D-07/D-08/D-09: once seen, floor is whisper; amplitude scales linearly with normalized rate.
|
||||
func (l *Layer) UpdateTarget(count int64, maxCount int64) {
|
||||
if count > 0 {
|
||||
l.seen = true
|
||||
}
|
||||
if !l.seen {
|
||||
l.targetAmp = 0.0
|
||||
return
|
||||
}
|
||||
normalizedRate := 0.0
|
||||
if maxCount > 0 {
|
||||
normalizedRate = float64(count) / float64(maxCount)
|
||||
}
|
||||
l.targetAmp = l.whisper + (1.0-l.whisper)*normalizedRate
|
||||
}
|
||||
|
||||
// AdvanceSample renders one sample and advances the EMA amplitude toward target.
|
||||
// Returns the raw mono sample (before pan/gain).
|
||||
func (l *Layer) AdvanceSample() float64 {
|
||||
sample := l.Osc.Advance(l.Config.Harmonics)
|
||||
l.currentAmp += l.alpha * (l.targetAmp - l.currentAmp)
|
||||
return sample * l.currentAmp
|
||||
}
|
||||
|
||||
// CurrentAmp returns the current amplitude (for testing).
|
||||
func (l *Layer) CurrentAmp() float64 {
|
||||
return l.currentAmp
|
||||
}
|
||||
|
||||
// TargetAmp returns the target amplitude (for testing).
|
||||
func (l *Layer) TargetAmp() float64 {
|
||||
return l.targetAmp
|
||||
}
|
||||
|
||||
// Seen returns whether this layer has ever received traffic (for testing).
|
||||
func (l *Layer) Seen() bool {
|
||||
return l.seen
|
||||
}
|
||||
```
|
||||
|
||||
Write tests FIRST (RED), then create the implementation files (GREEN). Run `go test ./synth/...` after each.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- synth/config.go contains `var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{`
|
||||
- synth/config.go contains `WhisperFloor = 0.03`
|
||||
- synth/config.go contains `SampleRate = 44100`
|
||||
- synth/config.go contains `GainPerLayer`
|
||||
- synth/oscillator.go contains `func (o *Oscillator) Advance(harmonics []HarmonicDef) float64`
|
||||
- synth/oscillator.go contains `o.phase -= 1.0` (NOT math.Mod)
|
||||
- synth/layer.go contains `func (l *Layer) UpdateTarget(count int64, maxCount int64)`
|
||||
- synth/layer.go contains `func (l *Layer) AdvanceSample() float64`
|
||||
- synth/layer.go contains `l.whisper + (1.0-l.whisper)*normalizedRate`
|
||||
- synth/config_test.go contains `TestAllClassesHaveConfig`
|
||||
- synth/config_test.go contains `TestFrequenciesInRange`
|
||||
- synth/layer_test.go contains `TestEMAAmplitudeRise`
|
||||
- synth/layer_test.go contains `TestWhisperFloor`
|
||||
- `go test ./synth/...` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>All 11 traffic classes have config entries with unique frequencies in 60-800 Hz. Oscillator produces correct waveform samples. EMA amplitude smoothing converges toward target with whisper floor enforcement. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./synth/... -v` passes all tests
|
||||
- `CGO_ENABLED=1 go build ./...` succeeds (gcc + go-lame working)
|
||||
- `grep -c "ClassFreqConfigs" synth/config.go` returns at least 1
|
||||
- Every class from `classify.AllClasses()` has config entry (verified by TestAllClassesHaveConfig)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- synth/config.go defines FreqConfig for all 11 TrafficClass values with frequencies in 60-800 Hz range
|
||||
- synth/oscillator.go implements phase-accumulator with harmonic rendering
|
||||
- synth/layer.go implements EMA amplitude smoothing with whisper floor
|
||||
- All tests in synth/ pass
|
||||
- gcc, ffprobe, and go-lame are installed and working
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-audio-synthesis-engine/02-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 02-audio-synthesis-engine
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- synth/bank.go
|
||||
- synth/mixer.go
|
||||
- synth/bank_test.go
|
||||
- synth/mixer_test.go
|
||||
autonomous: true
|
||||
requirements: [SYNTH-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "OscillatorBank updates all 11 layers from a WindowSnapshot and renders stereo PCM frames"
|
||||
- "Mixer sums 11 layers with fixed 1/11 gain per layer — peak sum never exceeds 1.0"
|
||||
- "Stereo panning uses constant-power pan law producing distinct L/R values for non-center sources"
|
||||
- "Full render pipeline: WindowSnapshot -> OscillatorBank.RenderWindow -> [][2]float64 stereo frames"
|
||||
artifacts:
|
||||
- path: "synth/bank.go"
|
||||
provides: "OscillatorBank with NewBank, RenderWindow methods"
|
||||
contains: "func (b *OscillatorBank) RenderWindow"
|
||||
- path: "synth/mixer.go"
|
||||
provides: "panGains constant-power function and float64-to-int16 conversion"
|
||||
contains: "func PanGains"
|
||||
key_links:
|
||||
- from: "synth/bank.go"
|
||||
to: "synth/layer.go"
|
||||
via: "Bank holds map[TrafficClass]*Layer"
|
||||
pattern: "map\\[classify\\.TrafficClass\\]\\*Layer"
|
||||
- from: "synth/bank.go"
|
||||
to: "synth/config.go"
|
||||
via: "Reads ClassFreqConfigs to initialize layers"
|
||||
pattern: "ClassFreqConfigs"
|
||||
- from: "synth/mixer.go"
|
||||
to: "synth/bank.go"
|
||||
via: "StereoFramesToInt16Bytes converts bank output to encoder-ready bytes"
|
||||
pattern: "StereoFramesToInt16Bytes"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the oscillator bank (11 layers driven by WindowSnapshot) and stereo mixer with int16 PCM conversion. This completes SYNTH-03 (mixing without distortion) and prepares the PCM output format needed by the MP3 encoder in Plan 03.
|
||||
|
||||
Purpose: The bank is the core synthesis engine that transforms traffic snapshots into stereo audio frames. The mixer ensures no clipping via fixed gain and provides constant-power stereo panning.
|
||||
|
||||
Output: `synth/bank.go`, `synth/mixer.go` with tests.
|
||||
</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
|
||||
|
||||
@classify/types.go
|
||||
|
||||
<interfaces>
|
||||
<!-- From synth/config.go (created in Plan 01) -->
|
||||
From synth/config.go:
|
||||
```go
|
||||
const (
|
||||
SampleRate = 44100
|
||||
WindowMs = 500
|
||||
SamplesPerWindow = SampleRate * WindowMs / 1000 // 22050
|
||||
NumLayers = 11
|
||||
GainPerLayer = 1.0 / float64(NumLayers)
|
||||
WhisperFloor = 0.03
|
||||
)
|
||||
|
||||
type HarmonicDef struct {
|
||||
Ratio int
|
||||
Amplitude float64
|
||||
}
|
||||
|
||||
type FreqConfig struct {
|
||||
BaseHz float64
|
||||
Harmonics []HarmonicDef
|
||||
Pan float64
|
||||
}
|
||||
|
||||
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... }
|
||||
```
|
||||
|
||||
From synth/oscillator.go:
|
||||
```go
|
||||
func NewOscillator(freq float64, sampleRate int) *Oscillator
|
||||
func (o *Oscillator) Advance(harmonics []HarmonicDef) float64
|
||||
```
|
||||
|
||||
From synth/layer.go:
|
||||
```go
|
||||
func NewLayer(cfg FreqConfig, sampleRate int, tau float64) *Layer
|
||||
func (l *Layer) UpdateTarget(count int64, maxCount int64)
|
||||
func (l *Layer) AdvanceSample() float64
|
||||
func (l *Layer) CurrentAmp() float64
|
||||
```
|
||||
|
||||
From classify/types.go:
|
||||
```go
|
||||
func AllClasses() []TrafficClass
|
||||
type WindowSnapshot struct {
|
||||
Counts map[TrafficClass]int64
|
||||
TotalPackets int64
|
||||
WindowIndex int
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Stereo mixer utilities</name>
|
||||
<files>synth/mixer.go, synth/mixer_test.go</files>
|
||||
<read_first>
|
||||
- synth/config.go (constants: GainPerLayer, SamplesPerWindow, SampleRate)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 3: constant-power panning, Pattern 4: int16 conversion)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10, D-11, D-12, D-13)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestPanGainsCenter: PanGains(0.0) returns gainL ~= 0.707, gainR ~= 0.707 (cos(pi/4), sin(pi/4))
|
||||
- TestPanGainsFullLeft: PanGains(-1.0) returns gainL ~= 1.0, gainR ~= 0.0
|
||||
- TestPanGainsFullRight: PanGains(1.0) returns gainL ~= 0.0, gainR ~= 1.0
|
||||
- TestPanGainsPowerPreserved: For any pan value, gainL^2 + gainR^2 ~= 1.0 (constant power)
|
||||
- TestStereoFramesToInt16Bytes: [][2]float64{{1.0, -1.0}} produces 4 bytes: int16(32767) LE then int16(-32767) LE
|
||||
- TestStereoFramesToInt16BytesZero: [][2]float64{{0.0, 0.0}} produces 4 zero bytes
|
||||
- TestClampPreventsOverflow: float64 value > 1.0 is clamped to 1.0 before int16 conversion (no wrap-around)
|
||||
</behavior>
|
||||
<action>
|
||||
**synth/mixer.go** — Constant-power pan law (per D-11) and PCM conversion utilities:
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
)
|
||||
|
||||
// PanGains returns left and right channel gains for a pan position p in [-1, 1].
|
||||
// Uses constant-power (equal-power) pan law: cos/sin mapping.
|
||||
// Per D-11: bass frequencies center, mid spread L/R, higher frequencies wider.
|
||||
func PanGains(p float64) (gainL, gainR float64) {
|
||||
angle := (p + 1.0) / 2.0 * math.Pi / 2.0
|
||||
return math.Cos(angle), math.Sin(angle)
|
||||
}
|
||||
|
||||
// StereoFramesToInt16Bytes converts [][2]float64 stereo frames to interleaved
|
||||
// little-endian int16 bytes suitable for go-lame's Write method.
|
||||
// Clamps values to [-1.0, 1.0] before conversion.
|
||||
// Output format: [L0_lo, L0_hi, R0_lo, R0_hi, L1_lo, L1_hi, R1_lo, R1_hi, ...]
|
||||
func StereoFramesToInt16Bytes(frames [][2]float64) []byte {
|
||||
buf := make([]byte, len(frames)*4) // 2 channels * 2 bytes per sample
|
||||
for i, frame := range frames {
|
||||
l := clamp(frame[0])
|
||||
r := clamp(frame[1])
|
||||
binary.LittleEndian.PutUint16(buf[i*4:], uint16(int16(l*32767)))
|
||||
binary.LittleEndian.PutUint16(buf[i*4+2:], uint16(int16(r*32767)))
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func clamp(v float64) float64 {
|
||||
if v > 1.0 {
|
||||
return 1.0
|
||||
}
|
||||
if v < -1.0 {
|
||||
return -1.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
Write tests FIRST (RED), then implementation (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestPan|TestStereo|TestClamp" -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- synth/mixer.go contains `func PanGains(p float64) (gainL, gainR float64)`
|
||||
- synth/mixer.go contains `func StereoFramesToInt16Bytes(frames [][2]float64) []byte`
|
||||
- synth/mixer.go contains `math.Cos(angle), math.Sin(angle)` (constant-power, not linear)
|
||||
- synth/mixer.go contains `func clamp(v float64) float64`
|
||||
- synth/mixer_test.go contains `TestPanGainsCenter`
|
||||
- synth/mixer_test.go contains `TestStereoFramesToInt16Bytes`
|
||||
- `go test ./synth/... -run "TestPan|TestStereo|TestClamp"` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>Constant-power pan law produces correct L/R gains for all positions. Stereo frames convert to interleaved int16 LE bytes with clamping. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: OscillatorBank — multi-layer rendering from WindowSnapshot</name>
|
||||
<files>synth/bank.go, synth/bank_test.go</files>
|
||||
<read_first>
|
||||
- synth/config.go (ClassFreqConfigs, GainPerLayer, SamplesPerWindow, NumLayers)
|
||||
- synth/layer.go (NewLayer, UpdateTarget, AdvanceSample)
|
||||
- synth/mixer.go (PanGains)
|
||||
- classify/types.go (AllClasses, WindowSnapshot)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-RESEARCH.md (Pattern 6: per-window render loop)
|
||||
- .planning/phases/02-audio-synthesis-engine/02-CONTEXT.md (D-10: fixed 1/11 gain)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestNewBankHas11Layers: NewBank() creates exactly 11 layers, one per TrafficClass
|
||||
- TestRenderWindowOutputLength: RenderWindow with any snapshot returns exactly SamplesPerWindow (22050) stereo frames
|
||||
- TestRenderWindowSilentWhenNoTraffic: RenderWindow with empty counts (all zeros, no class ever seen) produces all-zero frames
|
||||
- TestRenderWindowNonZeroWithTraffic: RenderWindow with ClassICMP count=100 produces non-zero L and R samples
|
||||
- TestMixerNoClip: RenderWindow with ALL 11 classes at max count — no frame has |L| > 1.0 or |R| > 1.0 (D-10 guarantees this)
|
||||
- TestStereoPan: RenderWindow with only ClassDHCP (pan=-0.75) — left channel RMS > right channel RMS (wide-left)
|
||||
- TestMultipleWindowsEMAConvergence: RenderWindow called 5 times with same snapshot — later windows have higher amplitude than first (EMA ramp-up)
|
||||
</behavior>
|
||||
<action>
|
||||
**synth/bank.go** — The core synthesis engine. Per D-10, each layer gets GainPerLayer (1/11) of headroom:
|
||||
|
||||
```go
|
||||
package synth
|
||||
|
||||
import "github.com/netsynth/netsynth/classify"
|
||||
|
||||
// OscillatorBank holds 11 synthesis layers, one per TrafficClass.
|
||||
// It consumes WindowSnapshot data and renders stereo PCM frames.
|
||||
type OscillatorBank struct {
|
||||
layers map[classify.TrafficClass]*Layer
|
||||
tau float64
|
||||
}
|
||||
|
||||
// NewBank creates an OscillatorBank with one Layer per TrafficClass.
|
||||
// tau is the EMA time constant in seconds (use 1.0 for D-07's "1-2 second" feel).
|
||||
func NewBank(tau float64) *OscillatorBank {
|
||||
b := &OscillatorBank{
|
||||
layers: make(map[classify.TrafficClass]*Layer, NumLayers),
|
||||
tau: tau,
|
||||
}
|
||||
for _, class := range classify.AllClasses() {
|
||||
cfg := ClassFreqConfigs[class]
|
||||
b.layers[class] = NewLayer(cfg, SampleRate, tau)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RenderWindow updates amplitude targets from snap, then renders SamplesPerWindow
|
||||
// stereo frames. Each frame is [2]float64{left, right} with values in [-1, 1].
|
||||
func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64 {
|
||||
// Find max count for normalization
|
||||
var maxCount int64
|
||||
for _, count := range snap.Counts {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// Update target amplitudes for all layers
|
||||
for _, class := range classify.AllClasses() {
|
||||
count := snap.Counts[class]
|
||||
b.layers[class].UpdateTarget(count, maxCount)
|
||||
}
|
||||
|
||||
// Render frames
|
||||
frames := make([][2]float64, SamplesPerWindow)
|
||||
for i := range frames {
|
||||
var sumL, sumR float64
|
||||
for _, class := range classify.AllClasses() {
|
||||
layer := b.layers[class]
|
||||
sample := layer.AdvanceSample()
|
||||
gainL, gainR := PanGains(layer.Config.Pan)
|
||||
sumL += sample * GainPerLayer * gainL
|
||||
sumR += sample * GainPerLayer * gainR
|
||||
}
|
||||
frames[i] = [2]float64{sumL, sumR}
|
||||
}
|
||||
return frames
|
||||
}
|
||||
```
|
||||
|
||||
Key design points:
|
||||
- `GainPerLayer` (1/11) is applied during mixing, not in the layer (per D-10). This ensures 11 max-amplitude layers sum to exactly 1.0.
|
||||
- `classify.AllClasses()` is used for iteration order consistency.
|
||||
- The `AdvanceSample()` call both advances the oscillator phase AND applies EMA smoothing (from layer.go).
|
||||
|
||||
Write tests FIRST (RED), then implementation (GREEN).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/dev/workspace/yoloyolo && go test ./synth/... -run "TestNewBank|TestRenderWindow|TestMixerNoClip|TestStereoPan|TestMultipleWindows" -v -count=1</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- synth/bank.go contains `func NewBank(tau float64) *OscillatorBank`
|
||||
- synth/bank.go contains `func (b *OscillatorBank) RenderWindow(snap classify.WindowSnapshot) [][2]float64`
|
||||
- synth/bank.go contains `GainPerLayer` (the 1/11 gain applied per layer)
|
||||
- synth/bank.go imports `github.com/netsynth/netsynth/classify`
|
||||
- synth/bank_test.go contains `TestMixerNoClip`
|
||||
- synth/bank_test.go contains `TestStereoPan`
|
||||
- synth/bank_test.go contains `TestRenderWindowOutputLength`
|
||||
- `go test ./synth/...` exits 0 (full synth package passes)
|
||||
</acceptance_criteria>
|
||||
<done>OscillatorBank renders 22050 stereo frames per window. 11 layers mixed with 1/11 gain never clip. Panned sources produce asymmetric L/R output. EMA converges over multiple windows. All synth tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `go test ./synth/... -v` passes all tests (config, oscillator, layer, mixer, bank)
|
||||
- No test contains a TODO or Skip marker
|
||||
- `grep -r "GainPerLayer" synth/bank.go` confirms fixed-gain mixing
|
||||
- `grep "PanGains" synth/mixer.go synth/bank.go` confirms pan is used in both files
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- OscillatorBank creates 11 layers from ClassFreqConfigs and renders stereo PCM from WindowSnapshot
|
||||
- Constant-power panning produces correct L/R gains per D-12 positions
|
||||
- Fixed 1/11 gain per layer prevents clipping by construction (D-10)
|
||||
- Int16 byte conversion with clamping ready for go-lame encoder
|
||||
- All synth/ tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-audio-synthesis-engine/02-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user